This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -1,10 +1,10 @@
|
||||
import { User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { updateUser } from "@/actions/admin-user-edit";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import { User } from "lucide-react";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -12,146 +12,186 @@ export const dynamic = "force-dynamic";
|
||||
const DUCKETS_TYPE = 0;
|
||||
const DIAMONDS_TYPE = 5;
|
||||
|
||||
export default async function AdminUserEdit({ params }: { params: Promise<{ id: string }> }) {
|
||||
// Gate the page with the SAME helper the action re-checks.
|
||||
await requireStaff();
|
||||
export default async function AdminUserEdit({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
// Gate the page with the SAME helper the action re-checks.
|
||||
await requireStaff();
|
||||
|
||||
const { id } = await params;
|
||||
const t = await getTranslations("pages.admin.users.editForm");
|
||||
const userId = Number(id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) notFound();
|
||||
const { id } = await params;
|
||||
const t = await getTranslations("pages.admin.users.editForm");
|
||||
const userId = Number(id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) notFound();
|
||||
|
||||
let user: {
|
||||
id: number;
|
||||
username: string;
|
||||
mail: string | null;
|
||||
motto: string;
|
||||
look: string;
|
||||
rank: number;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
points: number;
|
||||
} | null = null;
|
||||
let duckets = 0;
|
||||
let diamonds = 0;
|
||||
let user: {
|
||||
id: number;
|
||||
username: string;
|
||||
mail: string | null;
|
||||
motto: string;
|
||||
look: string;
|
||||
rank: number;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
points: number;
|
||||
} | null = null;
|
||||
let duckets = 0;
|
||||
let diamonds = 0;
|
||||
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
mail: true,
|
||||
motto: true,
|
||||
look: true,
|
||||
rank: true,
|
||||
credits: true,
|
||||
pixels: true,
|
||||
points: true,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
const currencies = await prisma.usersCurrency.findMany({
|
||||
where: { userId, type: { in: [DUCKETS_TYPE, DIAMONDS_TYPE] } },
|
||||
select: { type: true, amount: true },
|
||||
});
|
||||
for (const c of currencies) {
|
||||
if (c.type === DUCKETS_TYPE) duckets = c.amount;
|
||||
else if (c.type === DIAMONDS_TYPE) diamonds = c.amount;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return (
|
||||
<main>
|
||||
<p className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
<Link href="/admin/users">← {t("title")}</Link>
|
||||
</p>
|
||||
<h1>{t("title")}</h1>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted">{t("loadError")}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
mail: true,
|
||||
motto: true,
|
||||
look: true,
|
||||
rank: true,
|
||||
credits: true,
|
||||
pixels: true,
|
||||
points: true,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
const currencies = await prisma.usersCurrency.findMany({
|
||||
where: { userId, type: { in: [DUCKETS_TYPE, DIAMONDS_TYPE] } },
|
||||
select: { type: true, amount: true },
|
||||
});
|
||||
for (const c of currencies) {
|
||||
if (c.type === DUCKETS_TYPE) duckets = c.amount;
|
||||
else if (c.type === DIAMONDS_TYPE) diamonds = c.amount;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return (
|
||||
<main>
|
||||
<p className="text-sm theme-text-muted dark:theme-text-muted">
|
||||
<Link href="/admin/users">← {t("title")}</Link>
|
||||
</p>
|
||||
<h1>{t("title")}</h1>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted">
|
||||
{t("loadError")}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) notFound();
|
||||
if (!user) notFound();
|
||||
|
||||
return (
|
||||
<main>
|
||||
<p className="text-sm theme-text-muted dark:theme-text-muted mb-2">
|
||||
<Link href={`/admin/users/${user.id}`}>← {user.username}</Link>
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<User size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("title", { username: user.username })}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">{t("idLabel", { id: user.id })}</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<main>
|
||||
<p className="text-sm theme-text-muted dark:theme-text-muted mb-2">
|
||||
<Link href={`/admin/users/${user.id}`}>← {user.username}</Link>
|
||||
</p>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
|
||||
<User size={20} className="text-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
|
||||
{t("title", { username: user.username })}
|
||||
</h1>
|
||||
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
|
||||
{t("idLabel", { id: user.id })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={updateUser} className="admin-card">
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
<form action={updateUser} className="admin-card">
|
||||
<input type="hidden" name="id" value={user.id} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
{t("email")}
|
||||
<input name="mail" type="email" defaultValue={user.mail ?? ""} maxLength={500} />
|
||||
</label>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
{t("email")}
|
||||
<input
|
||||
name="mail"
|
||||
type="email"
|
||||
defaultValue={user.mail ?? ""}
|
||||
maxLength={500}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t("rank")}
|
||||
<input name="rank" type="number" min={1} defaultValue={user.rank} />
|
||||
</label>
|
||||
<label>
|
||||
{t("rank")}
|
||||
<input name="rank" type="number" min={1} defaultValue={user.rank} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t("motto")}
|
||||
<input name="motto" defaultValue={user.motto} maxLength={127} />
|
||||
</label>
|
||||
<label>
|
||||
{t("motto")}
|
||||
<input name="motto" defaultValue={user.motto} maxLength={127} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t("look")}
|
||||
<input name="look" defaultValue={user.look} maxLength={256} />
|
||||
</label>
|
||||
<label>
|
||||
{t("look")}
|
||||
<input name="look" defaultValue={user.look} maxLength={256} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t("credits")}
|
||||
<input name="credits" type="number" min={0} defaultValue={user.credits} />
|
||||
</label>
|
||||
<label>
|
||||
{t("credits")}
|
||||
<input
|
||||
name="credits"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={user.credits}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t("pixels")}
|
||||
<input name="pixels" type="number" min={0} defaultValue={user.pixels} />
|
||||
</label>
|
||||
<label>
|
||||
{t("pixels")}
|
||||
<input
|
||||
name="pixels"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={user.pixels}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t("points")}
|
||||
<input name="points" type="number" min={0} defaultValue={user.points} />
|
||||
</label>
|
||||
<label>
|
||||
{t("points")}
|
||||
<input
|
||||
name="points"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={user.points}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t("duckets")}
|
||||
<input name="duckets" type="number" min={0} defaultValue={duckets} />
|
||||
</label>
|
||||
<label>
|
||||
{t("duckets")}
|
||||
<input
|
||||
name="duckets"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={duckets}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{t("diamonds")}
|
||||
<input name="diamonds" type="number" min={0} defaultValue={diamonds} />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
{t("diamonds")}
|
||||
<input
|
||||
name="diamonds"
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={diamonds}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted">{t("hint")}</p>
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted">
|
||||
{t("hint")}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("save")}
|
||||
</button>
|
||||
<Link href={`/admin/users/${user.id}`} className="btn">
|
||||
{t("cancel")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t("save")}
|
||||
</button>
|
||||
<Link href={`/admin/users/${user.id}`} className="btn">
|
||||
{t("cancel")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +1,50 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function UserDetailLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
|
||||
{/* User header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-16 w-16 rounded" />
|
||||
<div>
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* User header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-16 w-16 rounded" />
|
||||
<div>
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Form */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-10 w-24 mt-4" />
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Form */}
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-24" />
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-10 w-24 mt-4" />
|
||||
</div>
|
||||
|
||||
{/* Side cards */}
|
||||
<div className="space-y-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{/* Side cards */}
|
||||
<div className="space-y-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { redirect } from "next/navigation";
|
||||
|
||||
/** Legacy route — redirects to /admin/users/show/[id] */
|
||||
export default async function UserLegacyPage({
|
||||
params,
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
redirect(`/admin/users/show/${id}`);
|
||||
const { id } = await params;
|
||||
redirect(`/admin/users/show/${id}`);
|
||||
}
|
||||
@@ -4,107 +4,126 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface ChatMessage {
|
||||
id: number;
|
||||
roomId: number;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
id: number;
|
||||
roomId: number;
|
||||
timestamp: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface CommandEntry {
|
||||
id: number;
|
||||
timestamp: number;
|
||||
command: string;
|
||||
params: string;
|
||||
succes: string;
|
||||
id: number;
|
||||
timestamp: number;
|
||||
command: string;
|
||||
params: string;
|
||||
succes: string;
|
||||
}
|
||||
|
||||
interface UserActivityLogProps {
|
||||
chatLogs: ChatMessage[];
|
||||
commandLogs: CommandEntry[];
|
||||
chatLogs: ChatMessage[];
|
||||
commandLogs: CommandEntry[];
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
if (!ts) return "-";
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
if (!ts) return "-";
|
||||
const d = new Date(ts * 1000);
|
||||
return d.toLocaleString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function UserActivityLog({ chatLogs, commandLogs }: UserActivityLogProps) {
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Chat Messages */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Recent Messages ({chatLogs.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chatLogs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No recent messages.</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{chatLogs.map((log) => (
|
||||
<div key={log.id} className="rounded-md border px-3 py-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<Link
|
||||
href={`/admin/rooms/${log.roomId}`}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Room #{log.roomId}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">{formatTime(log.timestamp)}</span>
|
||||
</div>
|
||||
<p className="break-words">{log.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
export function UserActivityLog({
|
||||
chatLogs,
|
||||
commandLogs,
|
||||
}: UserActivityLogProps) {
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Chat Messages */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Recent Messages ({chatLogs.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chatLogs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No recent messages.</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{chatLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<Link
|
||||
href={`/admin/rooms/${log.roomId}`}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Room #{log.roomId}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(log.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="break-words">{log.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Commands */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
Recent Commands ({commandLogs.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{commandLogs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No recent commands.</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{commandLogs.map((log) => (
|
||||
<div key={log.id} className="rounded-md border px-3 py-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<code className="font-mono text-xs font-bold">:{log.command}</code>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={log.succes === "yes" ? "default" : "destructive"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{log.succes === "yes" ? "OK" : "FAIL"}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">{formatTime(log.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{log.params && (
|
||||
<p className="text-xs text-muted-foreground mt-1 break-words">{log.params}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
{/* Commands */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
Recent Commands ({commandLogs.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{commandLogs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No recent commands.</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{commandLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<code className="font-mono text-xs font-bold">
|
||||
:{log.command}
|
||||
</code>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
log.succes === "yes" ? "default" : "destructive"
|
||||
}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{log.succes === "yes" ? "OK" : "FAIL"}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTime(log.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{log.params && (
|
||||
<p className="text-xs text-muted-foreground mt-1 break-words">
|
||||
{log.params}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,84 +9,86 @@ import { Input } from "@/components/ui/input";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
interface BadgeInfo {
|
||||
id: number;
|
||||
badgeCode: string;
|
||||
id: number;
|
||||
badgeCode: string;
|
||||
}
|
||||
|
||||
interface UserBadgesSectionProps {
|
||||
userId: number;
|
||||
badges: BadgeInfo[];
|
||||
userId: number;
|
||||
badges: BadgeInfo[];
|
||||
}
|
||||
|
||||
export function UserBadgesSection({ userId, badges }: UserBadgesSectionProps) {
|
||||
const { isPending, run } = useServerAction();
|
||||
const [newBadge, setNewBadge] = useState("");
|
||||
const { isPending, run } = useServerAction();
|
||||
const [newBadge, setNewBadge] = useState("");
|
||||
|
||||
function handleAddBadge() {
|
||||
if (!newBadge.trim()) return;
|
||||
run(() => giveBadge({ userId, badgeCode: newBadge.trim() }), {
|
||||
successMessage: `Badge "${newBadge.trim()}" added.`,
|
||||
onSuccess: () => setNewBadge(""),
|
||||
});
|
||||
}
|
||||
function handleAddBadge() {
|
||||
if (!newBadge.trim()) return;
|
||||
run(() => giveBadge({ userId, badgeCode: newBadge.trim() }), {
|
||||
successMessage: `Badge "${newBadge.trim()}" added.`,
|
||||
onSuccess: () => setNewBadge(""),
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemoveBadge(badgeCode: string) {
|
||||
run(() => removeBadge({ userId, badgeCode }), {
|
||||
successMessage: `Badge "${badgeCode}" removed.`,
|
||||
});
|
||||
}
|
||||
function handleRemoveBadge(badgeCode: string) {
|
||||
run(() => removeBadge({ userId, badgeCode }), {
|
||||
successMessage: `Badge "${badgeCode}" removed.`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Badges ({badges.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newBadge}
|
||||
onChange={(e) => setNewBadge(e.target.value)}
|
||||
placeholder="Badge code..."
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAddBadge()}
|
||||
/>
|
||||
<Button size="icon" onClick={handleAddBadge} disabled={isPending}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Badges ({badges.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newBadge}
|
||||
onChange={(e) => setNewBadge(e.target.value)}
|
||||
placeholder="Badge code..."
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAddBadge()}
|
||||
/>
|
||||
<Button size="icon" onClick={handleAddBadge} disabled={isPending}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{badges.map((badge) => (
|
||||
<div
|
||||
key={badge.id}
|
||||
className="group flex items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-sm hover:border-destructive/50 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={
|
||||
process.env.NEXT_PUBLIC_BADGE_URL
|
||||
? `${process.env.NEXT_PUBLIC_BADGE_URL}/${badge.badgeCode}.gif`
|
||||
: `/swf/c_images/album1584/${badge.badgeCode}.gif`
|
||||
}
|
||||
alt={badge.badgeCode}
|
||||
className="h-5 w-5"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<span>{badge.badgeCode}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveBadge(badge.badgeCode)}
|
||||
disabled={isPending}
|
||||
className="ml-0.5 rounded-full p-0.5 opacity-0 group-hover:opacity-100 hover:bg-destructive/10 text-destructive transition-opacity disabled:opacity-50"
|
||||
title={`Remove ${badge.badgeCode}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{badges.length === 0 && <p className="text-sm text-muted-foreground">No badges</p>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{badges.map((badge) => (
|
||||
<div
|
||||
key={badge.id}
|
||||
className="group flex items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-sm hover:border-destructive/50 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={
|
||||
process.env.NEXT_PUBLIC_BADGE_URL
|
||||
? `${process.env.NEXT_PUBLIC_BADGE_URL}/${badge.badgeCode}.gif`
|
||||
: `/swf/c_images/album1584/${badge.badgeCode}.gif`
|
||||
}
|
||||
alt={badge.badgeCode}
|
||||
className="h-5 w-5"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<span>{badge.badgeCode}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveBadge(badge.badgeCode)}
|
||||
disabled={isPending}
|
||||
className="ml-0.5 rounded-full p-0.5 opacity-0 group-hover:opacity-100 hover:bg-destructive/10 text-destructive transition-opacity disabled:opacity-50"
|
||||
title={`Remove ${badge.badgeCode}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{badges.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No badges</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -8,153 +8,179 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
interface BanInfo {
|
||||
id: number;
|
||||
reason: string;
|
||||
type: string;
|
||||
staffName: string;
|
||||
timestamp: number;
|
||||
banExpire: number;
|
||||
id: number;
|
||||
reason: string;
|
||||
type: string;
|
||||
staffName: string;
|
||||
timestamp: number;
|
||||
banExpire: number;
|
||||
}
|
||||
|
||||
interface UserBanSectionProps {
|
||||
userId: number;
|
||||
username: string;
|
||||
bans: BanInfo[];
|
||||
userId: number;
|
||||
username: string;
|
||||
bans: BanInfo[];
|
||||
}
|
||||
|
||||
export function UserBanSection({ userId, username, bans }: UserBanSectionProps) {
|
||||
const { isPending, run } = useServerAction();
|
||||
const [showBanForm, setShowBanForm] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [duration, setDuration] = useState("24");
|
||||
const [banType, setBanType] = useState("account");
|
||||
export function UserBanSection({
|
||||
userId,
|
||||
username,
|
||||
bans,
|
||||
}: UserBanSectionProps) {
|
||||
const { isPending, run } = useServerAction();
|
||||
const [showBanForm, setShowBanForm] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [duration, setDuration] = useState("24");
|
||||
const [banType, setBanType] = useState("account");
|
||||
|
||||
const activeBan = bans.find((b) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return b.banExpire === 0 || b.banExpire > now;
|
||||
});
|
||||
const activeBan = bans.find((b) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return b.banExpire === 0 || b.banExpire > now;
|
||||
});
|
||||
|
||||
function handleBan() {
|
||||
run(
|
||||
() =>
|
||||
banUser({
|
||||
userId,
|
||||
reason,
|
||||
duration: parseInt(duration, 10),
|
||||
type: banType as "account" | "ip" | "machine",
|
||||
}),
|
||||
{
|
||||
successMessage: `${username} has been banned.`,
|
||||
onSuccess: () => {
|
||||
setShowBanForm(false);
|
||||
setReason("");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
function handleBan() {
|
||||
run(
|
||||
() =>
|
||||
banUser({
|
||||
userId,
|
||||
reason,
|
||||
duration: parseInt(duration, 10),
|
||||
type: banType as "account" | "ip" | "machine",
|
||||
}),
|
||||
{
|
||||
successMessage: `${username} has been banned.`,
|
||||
onSuccess: () => {
|
||||
setShowBanForm(false);
|
||||
setReason("");
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleUnban() {
|
||||
run(() => unbanUser({ userId }), {
|
||||
successMessage: `${username} has been unbanned.`,
|
||||
});
|
||||
}
|
||||
function handleUnban() {
|
||||
run(() => unbanUser({ userId }), {
|
||||
successMessage: `${username} has been unbanned.`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Bans</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{activeBan ? (
|
||||
<Button variant="outline" size="sm" onClick={handleUnban} disabled={isPending}>
|
||||
<ShieldOff className="mr-2 h-4 w-4" />
|
||||
Unban
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="destructive" size="sm" onClick={() => setShowBanForm(!showBanForm)}>
|
||||
<Ban className="mr-2 h-4 w-4" />
|
||||
Ban
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeBan && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<p className="text-sm font-medium text-destructive">Active Ban</p>
|
||||
<p className="text-sm">{activeBan.reason}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
By {activeBan.staffName} ·{" "}
|
||||
{activeBan.banExpire === 0
|
||||
? "Permanent"
|
||||
: `Expires ${new Date(activeBan.banExpire * 1000).toLocaleString()}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Bans</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{activeBan ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleUnban}
|
||||
disabled={isPending}
|
||||
>
|
||||
<ShieldOff className="mr-2 h-4 w-4" />
|
||||
Unban
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowBanForm(!showBanForm)}
|
||||
>
|
||||
<Ban className="mr-2 h-4 w-4" />
|
||||
Ban
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeBan && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<p className="text-sm font-medium text-destructive">Active Ban</p>
|
||||
<p className="text-sm">{activeBan.reason}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
By {activeBan.staffName} ·{" "}
|
||||
{activeBan.banExpire === 0
|
||||
? "Permanent"
|
||||
: `Expires ${new Date(activeBan.banExpire * 1000).toLocaleString()}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBanForm && !activeBan && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Reason</Label>
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Ban reason..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Duration (hours)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
placeholder="0 = permanent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select value={banType} onValueChange={setBanType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="account">Account</SelectItem>
|
||||
<SelectItem value="ip">IP</SelectItem>
|
||||
<SelectItem value="machine">Machine</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={handleBan} disabled={isPending || !reason}>
|
||||
{isPending ? "Banning..." : "Confirm Ban"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{showBanForm && !activeBan && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Reason</Label>
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Ban reason..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Duration (hours)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
placeholder="0 = permanent"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select value={banType} onValueChange={setBanType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="account">Account</SelectItem>
|
||||
<SelectItem value="ip">IP</SelectItem>
|
||||
<SelectItem value="machine">Machine</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleBan}
|
||||
disabled={isPending || !reason}
|
||||
>
|
||||
{isPending ? "Banning..." : "Confirm Ban"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bans.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">History</p>
|
||||
{bans.map((ban) => (
|
||||
<div key={ban.id} className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span>{ban.reason}</span>
|
||||
<span className="ml-2">
|
||||
<Badge variant="outline">{ban.type}</Badge>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(ban.timestamp * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
{bans.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">History</p>
|
||||
{bans.map((ban) => (
|
||||
<div
|
||||
key={ban.id}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div>
|
||||
<span>{ban.reason}</span>
|
||||
<span className="ml-2">
|
||||
<Badge variant="outline">{ban.type}</Badge>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(ban.timestamp * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -7,135 +7,165 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { type UpdateUserInput, updateUserSchema } from "@/lib/validators/user";
|
||||
|
||||
interface UserEditFormProps {
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
mail: string;
|
||||
rank: number;
|
||||
motto: string;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
diamonds: number;
|
||||
duckets: number;
|
||||
};
|
||||
/** Ranks loaded from the `permissions` table (id + human-readable name). */
|
||||
ranks?: Array<{ id: number; name: string }>;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
mail: string;
|
||||
rank: number;
|
||||
motto: string;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
diamonds: number;
|
||||
duckets: number;
|
||||
};
|
||||
/** Ranks loaded from the `permissions` table (id + human-readable name). */
|
||||
ranks?: Array<{ id: number; name: string }>;
|
||||
}
|
||||
|
||||
// Fallback used if the page does not supply the DB-loaded rank list.
|
||||
const FALLBACK_RANKS: Array<{ id: number; name: string }> = [
|
||||
{ id: 1, name: "User" },
|
||||
{ id: 2, name: "Bronze" },
|
||||
{ id: 3, name: "Silver" },
|
||||
{ id: 4, name: "Gold" },
|
||||
{ id: 5, name: "Moderator" },
|
||||
{ id: 6, name: "Admin" },
|
||||
{ id: 7, name: "Super Admin" },
|
||||
{ id: 1, name: "User" },
|
||||
{ id: 2, name: "Bronze" },
|
||||
{ id: 3, name: "Silver" },
|
||||
{ id: 4, name: "Gold" },
|
||||
{ id: 5, name: "Moderator" },
|
||||
{ id: 6, name: "Admin" },
|
||||
{ id: 7, name: "Super Admin" },
|
||||
];
|
||||
|
||||
export function UserEditForm({ user, ranks }: UserEditFormProps) {
|
||||
const rankOptions = ranks && ranks.length > 0 ? ranks : FALLBACK_RANKS;
|
||||
const { isPending, run } = useServerAction();
|
||||
const rankOptions = ranks && ranks.length > 0 ? ranks : FALLBACK_RANKS;
|
||||
const { isPending, run } = useServerAction();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<UpdateUserInput>({
|
||||
resolver: zodResolver(updateUserSchema),
|
||||
defaultValues: {
|
||||
username: user.username,
|
||||
mail: user.mail,
|
||||
rank: user.rank,
|
||||
motto: user.motto,
|
||||
credits: user.credits,
|
||||
pixels: user.pixels,
|
||||
diamonds: user.diamonds,
|
||||
duckets: user.duckets,
|
||||
},
|
||||
});
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<UpdateUserInput>({
|
||||
resolver: zodResolver(updateUserSchema),
|
||||
defaultValues: {
|
||||
username: user.username,
|
||||
mail: user.mail,
|
||||
rank: user.rank,
|
||||
motto: user.motto,
|
||||
credits: user.credits,
|
||||
pixels: user.pixels,
|
||||
diamonds: user.diamonds,
|
||||
duckets: user.duckets,
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(data: UpdateUserInput) {
|
||||
run(() => updateUser({ id: user.id, ...data }), {
|
||||
successMessage: "User updated successfully.",
|
||||
});
|
||||
}
|
||||
function onSubmit(data: UpdateUserInput) {
|
||||
run(() => updateUser({ id: user.id, ...data }), {
|
||||
successMessage: "User updated successfully.",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Edit User</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" {...register("username")} />
|
||||
{errors.username && <p className="text-sm text-destructive">{errors.username.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mail">Email</Label>
|
||||
<Input id="mail" type="email" {...register("mail")} />
|
||||
{errors.mail && <p className="text-sm text-destructive">{errors.mail.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Edit User</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input id="username" {...register("username")} />
|
||||
{errors.username && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.username.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mail">Email</Label>
|
||||
<Input id="mail" type="email" {...register("mail")} />
|
||||
{errors.mail && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.mail.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank">Rank</Label>
|
||||
<Select
|
||||
defaultValue={String(user.rank)}
|
||||
onValueChange={(v) => setValue("rank", parseInt(v, 10))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select rank" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rankOptions.map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>
|
||||
{r.id} — {r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motto">Motto</Label>
|
||||
<Input id="motto" {...register("motto")} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank">Rank</Label>
|
||||
<Select
|
||||
defaultValue={String(user.rank)}
|
||||
onValueChange={(v) => setValue("rank", parseInt(v, 10))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select rank" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rankOptions.map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>
|
||||
{r.id} — {r.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motto">Motto</Label>
|
||||
<Input id="motto" {...register("motto")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="credits">Credits</Label>
|
||||
<Input id="credits" type="number" {...register("credits", { valueAsNumber: true })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pixels">Pixels</Label>
|
||||
<Input id="pixels" type="number" {...register("pixels", { valueAsNumber: true })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="diamonds">Diamonds</Label>
|
||||
<Input id="diamonds" type="number" {...register("diamonds", { valueAsNumber: true })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duckets">Duckets</Label>
|
||||
<Input id="duckets" type="number" {...register("duckets", { valueAsNumber: true })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="credits">Credits</Label>
|
||||
<Input
|
||||
id="credits"
|
||||
type="number"
|
||||
{...register("credits", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pixels">Pixels</Label>
|
||||
<Input
|
||||
id="pixels"
|
||||
type="number"
|
||||
{...register("pixels", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="diamonds">Diamonds</Label>
|
||||
<Input
|
||||
id="diamonds"
|
||||
type="number"
|
||||
{...register("diamonds", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duckets">Duckets</Label>
|
||||
<Input
|
||||
id="duckets"
|
||||
type="number"
|
||||
{...register("duckets", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,206 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Coins, Copy, KeyRound, MessageCircle, Unplug, Volume2, VolumeX } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
Coins,
|
||||
Copy,
|
||||
KeyRound,
|
||||
MessageCircle,
|
||||
Unplug,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { alertUser, disconnectUser, muteUser, resetPassword, sendCredits, unmuteUser } from "@/actions/users";
|
||||
import {
|
||||
alertUser,
|
||||
disconnectUser,
|
||||
muteUser,
|
||||
resetPassword,
|
||||
sendCredits,
|
||||
unmuteUser,
|
||||
} from "@/actions/users";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
interface UserQuickActionsProps {
|
||||
userId: number;
|
||||
username: string;
|
||||
isOnline: boolean;
|
||||
userId: number;
|
||||
username: string;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
export function UserQuickActions({ userId, username, isOnline }: UserQuickActionsProps) {
|
||||
const { run, isPending } = useServerAction();
|
||||
export function UserQuickActions({
|
||||
userId,
|
||||
username,
|
||||
isOnline,
|
||||
}: UserQuickActionsProps) {
|
||||
const { run, isPending } = useServerAction();
|
||||
|
||||
// Reset password state
|
||||
const [newPassword, setNewPassword] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
// Reset password state
|
||||
const [newPassword, setNewPassword] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Alert state
|
||||
const [alertMessage, setAlertMessage] = useState("");
|
||||
// Alert state
|
||||
const [alertMessage, setAlertMessage] = useState("");
|
||||
|
||||
// Mute state
|
||||
const [muteDuration, setMuteDuration] = useState("10");
|
||||
// Mute state
|
||||
const [muteDuration, setMuteDuration] = useState("10");
|
||||
|
||||
// Credits state
|
||||
const [creditAmount, setCreditAmount] = useState("1000");
|
||||
// Credits state
|
||||
const [creditAmount, setCreditAmount] = useState("1000");
|
||||
|
||||
function handleResetPassword() {
|
||||
if (!confirm(`Reset password for ${username}? A new random password will be generated.`)) return;
|
||||
run(() => resetPassword({ userId }), {
|
||||
successMessage: "Password reset successfully.",
|
||||
onSuccess: (data) => {
|
||||
setNewPassword(data.newPassword as string);
|
||||
},
|
||||
});
|
||||
}
|
||||
function handleResetPassword() {
|
||||
if (
|
||||
!confirm(
|
||||
`Reset password for ${username}? A new random password will be generated.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
run(() => resetPassword({ userId }), {
|
||||
successMessage: "Password reset successfully.",
|
||||
onSuccess: (data) => {
|
||||
setNewPassword(data.newPassword as string);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
run(() => disconnectUser({ userId }), {
|
||||
successMessage: `${username} disconnected.`,
|
||||
});
|
||||
}
|
||||
function handleDisconnect() {
|
||||
run(() => disconnectUser({ userId }), {
|
||||
successMessage: `${username} disconnected.`,
|
||||
});
|
||||
}
|
||||
|
||||
function handleAlert() {
|
||||
if (!alertMessage.trim()) return;
|
||||
run(() => alertUser({ userId, message: alertMessage.trim() }), {
|
||||
successMessage: "Alert sent.",
|
||||
onSuccess: () => setAlertMessage(""),
|
||||
});
|
||||
}
|
||||
function handleAlert() {
|
||||
if (!alertMessage.trim()) return;
|
||||
run(() => alertUser({ userId, message: alertMessage.trim() }), {
|
||||
successMessage: "Alert sent.",
|
||||
onSuccess: () => setAlertMessage(""),
|
||||
});
|
||||
}
|
||||
|
||||
function handleMute() {
|
||||
const duration = Number(muteDuration);
|
||||
if (Number.isNaN(duration) || duration < 0) return;
|
||||
run(() => muteUser({ userId, duration }), {
|
||||
successMessage: `${username} muted.`,
|
||||
});
|
||||
}
|
||||
function handleMute() {
|
||||
const duration = Number(muteDuration);
|
||||
if (Number.isNaN(duration) || duration < 0) return;
|
||||
run(() => muteUser({ userId, duration }), {
|
||||
successMessage: `${username} muted.`,
|
||||
});
|
||||
}
|
||||
|
||||
function handleUnmute() {
|
||||
run(() => unmuteUser({ userId }), {
|
||||
successMessage: `${username} unmuted.`,
|
||||
});
|
||||
}
|
||||
function handleUnmute() {
|
||||
run(() => unmuteUser({ userId }), {
|
||||
successMessage: `${username} unmuted.`,
|
||||
});
|
||||
}
|
||||
|
||||
function handleSendCredits() {
|
||||
const amount = parseInt(creditAmount, 10);
|
||||
if (!amount || amount <= 0) return;
|
||||
run(() => sendCredits({ userId, amount }), {
|
||||
successMessage: `${amount.toLocaleString()} credits sent to ${username}.`,
|
||||
});
|
||||
}
|
||||
function handleSendCredits() {
|
||||
const amount = parseInt(creditAmount, 10);
|
||||
if (!amount || amount <= 0) return;
|
||||
run(() => sendCredits({ userId, amount }), {
|
||||
successMessage: `${amount.toLocaleString()} credits sent to ${username}.`,
|
||||
});
|
||||
}
|
||||
|
||||
function copyPassword() {
|
||||
if (newPassword) {
|
||||
navigator.clipboard.writeText(newPassword);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}
|
||||
function copyPassword() {
|
||||
if (newPassword) {
|
||||
navigator.clipboard.writeText(newPassword);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Row 1: Password + Disconnect */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetPassword}
|
||||
disabled={isPending}
|
||||
className="justify-start"
|
||||
>
|
||||
<KeyRound className="mr-2 h-4 w-4" />
|
||||
Reset Password
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
disabled={isPending || !isOnline}
|
||||
className="justify-start"
|
||||
>
|
||||
<Unplug className="mr-2 h-4 w-4" />
|
||||
Disconnect
|
||||
{isOnline && (
|
||||
<Badge variant="default" className="ml-auto text-[10px] bg-[var(--admin-success-subtle)]">
|
||||
Online
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Row 1: Password + Disconnect */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetPassword}
|
||||
disabled={isPending}
|
||||
className="justify-start"
|
||||
>
|
||||
<KeyRound className="mr-2 h-4 w-4" />
|
||||
Reset Password
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
disabled={isPending || !isOnline}
|
||||
className="justify-start"
|
||||
>
|
||||
<Unplug className="mr-2 h-4 w-4" />
|
||||
Disconnect
|
||||
{isOnline && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="ml-auto text-[10px] bg-[var(--admin-success-subtle)]"
|
||||
>
|
||||
Online
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Send Alert */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Send In-Game Alert</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Message to send..."
|
||||
value={alertMessage}
|
||||
onChange={(e) => setAlertMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAlert()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleAlert} disabled={isPending || !alertMessage.trim()} size="icon">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Send Alert */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Send In-Game Alert
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Message to send..."
|
||||
value={alertMessage}
|
||||
onChange={(e) => setAlertMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAlert()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAlert}
|
||||
disabled={isPending || !alertMessage.trim()}
|
||||
size="icon"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mute */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Mute / Unmute</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Minutes (0 = permanent)"
|
||||
value={muteDuration}
|
||||
onChange={(e) => setMuteDuration(e.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleMute} disabled={isPending} size="sm">
|
||||
<VolumeX className="mr-1.5 h-3.5 w-3.5" /> Mute
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleUnmute} disabled={isPending} size="sm">
|
||||
<Volume2 className="mr-1.5 h-3.5 w-3.5" /> Unmute
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mute */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Mute / Unmute
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Minutes (0 = permanent)"
|
||||
value={muteDuration}
|
||||
onChange={(e) => setMuteDuration(e.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleMute}
|
||||
disabled={isPending}
|
||||
size="sm"
|
||||
>
|
||||
<VolumeX className="mr-1.5 h-3.5 w-3.5" /> Mute
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleUnmute}
|
||||
disabled={isPending}
|
||||
size="sm"
|
||||
>
|
||||
<Volume2 className="mr-1.5 h-3.5 w-3.5" /> Unmute
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Credits */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Send Credits (RCON)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={creditAmount}
|
||||
onChange={(e) => setCreditAmount(e.target.value)}
|
||||
className="w-32"
|
||||
min={1}
|
||||
/>
|
||||
<Button variant="outline" onClick={handleSendCredits} disabled={isPending} size="sm">
|
||||
<Coins className="mr-1.5 h-3.5 w-3.5" /> Send Credits
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Send Credits */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Send Credits (RCON)
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={creditAmount}
|
||||
onChange={(e) => setCreditAmount(e.target.value)}
|
||||
className="w-32"
|
||||
min={1}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSendCredits}
|
||||
disabled={isPending}
|
||||
size="sm"
|
||||
>
|
||||
<Coins className="mr-1.5 h-3.5 w-3.5" /> Send Credits
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Password Reset Dialog */}
|
||||
<Dialog open={!!newPassword} onOpenChange={() => setNewPassword(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Password Reset</DialogTitle>
|
||||
<DialogDescription>
|
||||
New password generated for <strong>{username}</strong>. Copy it now — it won't be shown
|
||||
again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted p-3">
|
||||
<code className="flex-1 text-lg font-mono font-bold tracking-wider select-all">
|
||||
{newPassword}
|
||||
</code>
|
||||
<Button variant="ghost" size="icon" onClick={copyPassword}>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-[var(--admin-success)]" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
{/* Password Reset Dialog */}
|
||||
<Dialog open={!!newPassword} onOpenChange={() => setNewPassword(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Password Reset</DialogTitle>
|
||||
<DialogDescription>
|
||||
New password generated for <strong>{username}</strong>. Copy it
|
||||
now — it won't be shown again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-muted p-3">
|
||||
<code className="flex-1 text-lg font-mono font-bold tracking-wider select-all">
|
||||
{newPassword}
|
||||
</code>
|
||||
<Button variant="ghost" size="icon" onClick={copyPassword}>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-[var(--admin-success)]" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,62 +4,72 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface UserRoom {
|
||||
id: number;
|
||||
name: string;
|
||||
state: string;
|
||||
users: number;
|
||||
usersMax: number;
|
||||
id: number;
|
||||
name: string;
|
||||
state: string;
|
||||
users: number;
|
||||
usersMax: number;
|
||||
}
|
||||
|
||||
interface UserRoomsProps {
|
||||
rooms: UserRoom[];
|
||||
rooms: UserRoom[];
|
||||
}
|
||||
|
||||
const stateColors: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
||||
open: "default",
|
||||
locked: "secondary",
|
||||
password: "destructive",
|
||||
invisible: "outline",
|
||||
const stateColors: Record<
|
||||
string,
|
||||
"default" | "secondary" | "outline" | "destructive"
|
||||
> = {
|
||||
open: "default",
|
||||
locked: "secondary",
|
||||
password: "destructive",
|
||||
invisible: "outline",
|
||||
};
|
||||
|
||||
export function UserRooms({ rooms }: UserRoomsProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DoorOpen className="h-4 w-4" />
|
||||
Rooms ({rooms.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rooms.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">This user doesn't own any rooms.</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{rooms.map((room) => (
|
||||
<Link
|
||||
key={room.id}
|
||||
href={`/admin/rooms/${room.id}`}
|
||||
className="flex items-center gap-3 rounded-lg border px-3 py-2.5 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{room.name}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Badge variant={stateColors[room.state] || "outline"} className="text-[10px]">
|
||||
{room.state}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{room.users}/{room.usersMax}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-muted-foreground">#{room.id}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DoorOpen className="h-4 w-4" />
|
||||
Rooms ({rooms.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rooms.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This user doesn't own any rooms.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{rooms.map((room) => (
|
||||
<Link
|
||||
key={room.id}
|
||||
href={`/admin/rooms/${room.id}`}
|
||||
className="flex items-center gap-3 rounded-lg border px-3 py-2.5 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{room.name}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Badge
|
||||
variant={stateColors[room.state] || "outline"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{room.state}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{room.users}/{room.usersMax}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
#{room.id}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,193 +6,211 @@ import { toast } from "sonner";
|
||||
import { createUser } from "@/actions/users";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
|
||||
interface CreateUserDialogProps {
|
||||
sessionRank: number;
|
||||
sessionRank: number;
|
||||
}
|
||||
|
||||
const RANK_OPTIONS: { value: number; label: string }[] = [
|
||||
{ value: 1, label: "User (1)" },
|
||||
{ value: 2, label: "Bronze (2)" },
|
||||
{ value: 3, label: "Silver (3)" },
|
||||
{ value: 4, label: "Gold (4)" },
|
||||
{ value: 5, label: "Moderator (5)" },
|
||||
{ value: 6, label: "Admin (6)" },
|
||||
{ value: 7, label: "Super Admin (7)" },
|
||||
{ value: 1, label: "User (1)" },
|
||||
{ value: 2, label: "Bronze (2)" },
|
||||
{ value: 3, label: "Silver (3)" },
|
||||
{ value: 4, label: "Gold (4)" },
|
||||
{ value: 5, label: "Moderator (5)" },
|
||||
{ value: 6, label: "Admin (6)" },
|
||||
{ value: 7, label: "Super Admin (7)" },
|
||||
];
|
||||
|
||||
export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
|
||||
const { run, isPending } = useServerAction();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [mail, setMail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rank, setRank] = useState("1");
|
||||
const [motto, setMotto] = useState("");
|
||||
const { run, isPending } = useServerAction();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [mail, setMail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rank, setRank] = useState("1");
|
||||
const [motto, setMotto] = useState("");
|
||||
|
||||
const allowedRanks = RANK_OPTIONS.filter((r) => (sessionRank >= 7 ? true : r.value < sessionRank));
|
||||
const allowedRanks = RANK_OPTIONS.filter((r) =>
|
||||
sessionRank >= 7 ? true : r.value < sessionRank,
|
||||
);
|
||||
|
||||
function reset() {
|
||||
setUsername("");
|
||||
setMail("");
|
||||
setPassword("");
|
||||
setRank("1");
|
||||
setMotto("");
|
||||
}
|
||||
function reset() {
|
||||
setUsername("");
|
||||
setMail("");
|
||||
setPassword("");
|
||||
setRank("1");
|
||||
setMotto("");
|
||||
}
|
||||
|
||||
function generatePassword() {
|
||||
const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
||||
let out = "";
|
||||
const buf = new Uint32Array(12);
|
||||
crypto.getRandomValues(buf);
|
||||
for (let i = 0; i < buf.length; i++) out += charset[buf[i] % charset.length];
|
||||
setPassword(out);
|
||||
toast.message("Password generated", { description: out });
|
||||
}
|
||||
function generatePassword() {
|
||||
const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
||||
let out = "";
|
||||
const buf = new Uint32Array(12);
|
||||
crypto.getRandomValues(buf);
|
||||
for (let i = 0; i < buf.length; i++)
|
||||
out += charset[buf[i] % charset.length];
|
||||
setPassword(out);
|
||||
toast.message("Password generated", { description: out });
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
const u = username.trim();
|
||||
const m = mail.trim();
|
||||
if (!u || !m || !password) {
|
||||
toast.error("Username, email and password are required");
|
||||
return;
|
||||
}
|
||||
if (u.length < 3 || u.length > 20 || !/^[a-zA-Z0-9_-]+$/.test(u)) {
|
||||
toast.error("Invalid username (3-20 chars, letters/digits/_/-)");
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
toast.error("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
function handleCreate() {
|
||||
const u = username.trim();
|
||||
const m = mail.trim();
|
||||
if (!u || !m || !password) {
|
||||
toast.error("Username, email and password are required");
|
||||
return;
|
||||
}
|
||||
if (u.length < 3 || u.length > 20 || !/^[a-zA-Z0-9_-]+$/.test(u)) {
|
||||
toast.error("Invalid username (3-20 chars, letters/digits/_/-)");
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
toast.error("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
run(
|
||||
() =>
|
||||
createUser({
|
||||
username: u,
|
||||
mail: m,
|
||||
password,
|
||||
rank: Number(rank),
|
||||
motto: motto.trim(),
|
||||
}),
|
||||
{
|
||||
successMessage: `User "${u}" created.`,
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
run(
|
||||
() =>
|
||||
createUser({
|
||||
username: u,
|
||||
mail: m,
|
||||
password,
|
||||
rank: Number(rank),
|
||||
motto: motto.trim(),
|
||||
}),
|
||||
{
|
||||
successMessage: `User "${u}" created.`,
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
New User
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Account is created immediately with default look and 5,000 credits/duckets/diamonds.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-username">Username</Label>
|
||||
<Input
|
||||
id="cu-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="HabboName"
|
||||
autoComplete="off"
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-mail">Email</Label>
|
||||
<Input
|
||||
id="cu-mail"
|
||||
type="email"
|
||||
value={mail}
|
||||
onChange={(e) => setMail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-password">Password</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="cu-password"
|
||||
type="text"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Minimum 6 characters"
|
||||
autoComplete="new-password"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={generatePassword}>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Rank</Label>
|
||||
<Select value={rank} onValueChange={setRank}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowedRanks.map((r) => (
|
||||
<SelectItem key={r.value} value={String(r.value)}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-motto">Motto (optional)</Label>
|
||||
<Input
|
||||
id="cu-motto"
|
||||
value={motto}
|
||||
onChange={(e) => setMotto(e.target.value)}
|
||||
maxLength={127}
|
||||
placeholder="I'm new here!"
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
New User
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Account is created immediately with default look and 5,000
|
||||
credits/duckets/diamonds.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-username">Username</Label>
|
||||
<Input
|
||||
id="cu-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="HabboName"
|
||||
autoComplete="off"
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-mail">Email</Label>
|
||||
<Input
|
||||
id="cu-mail"
|
||||
type="email"
|
||||
value={mail}
|
||||
onChange={(e) => setMail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-password">Password</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="cu-password"
|
||||
type="text"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Minimum 6 characters"
|
||||
autoComplete="new-password"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={generatePassword}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Rank</Label>
|
||||
<Select value={rank} onValueChange={setRank}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowedRanks.map((r) => (
|
||||
<SelectItem key={r.value} value={String(r.value)}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cu-motto">Motto (optional)</Label>
|
||||
<Input
|
||||
id="cu-motto"
|
||||
value={motto}
|
||||
onChange={(e) => setMotto(e.target.value)}
|
||||
maxLength={127}
|
||||
placeholder="I'm new here!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCreate} disabled={isPending} className="w-full">
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +1,131 @@
|
||||
import { Calendar, Clock, ExternalLink, Globe, MonitorSmartphone, Shield } from "lucide-react";
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
MonitorSmartphone,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface UserAccountInfoProps {
|
||||
user: {
|
||||
accountCreated: number;
|
||||
lastLogin: number | null;
|
||||
ipRegister: string;
|
||||
ipCurrent: string;
|
||||
twoFactorConfirmed: number;
|
||||
machine_id: string;
|
||||
mail: string;
|
||||
gender: string;
|
||||
};
|
||||
user: {
|
||||
accountCreated: number;
|
||||
lastLogin: number | null;
|
||||
ipRegister: string;
|
||||
ipCurrent: string;
|
||||
twoFactorConfirmed: number;
|
||||
machine_id: string;
|
||||
mail: string;
|
||||
gender: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function UserAccountInfo({ user }: UserAccountInfoProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">Registered</span>
|
||||
<span className="ml-auto font-medium">
|
||||
{new Date(user.accountCreated * 1000).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">Last Login</span>
|
||||
<span className="ml-auto font-medium">
|
||||
{user.lastLogin
|
||||
? new Date(user.lastLogin * 1000).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">IP Register</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">{user.ipRegister}</code>
|
||||
<Link href={`/admin/users?search=${user.ipRegister}`} title="Find other users with this IP">
|
||||
<ExternalLink className="h-3 w-3 text-primary" />
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">IP Current</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">{user.ipCurrent}</code>
|
||||
<Link href={`/admin/users?search=${user.ipCurrent}`} title="Find other users with this IP">
|
||||
<ExternalLink className="h-3 w-3 text-primary" />
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Shield className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">2FA</span>
|
||||
<Badge variant={user.twoFactorConfirmed ? "default" : "outline"} className="ml-auto">
|
||||
{user.twoFactorConfirmed ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<MonitorSmartphone className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">Machine ID</span>
|
||||
<code className="ml-auto text-xs font-mono bg-muted px-2 py-0.5 rounded truncate max-w-[180px]">
|
||||
{user.machine_id || "N/A"}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Email</span>
|
||||
<span className="ml-auto font-medium">{user.mail}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Gender</span>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{user.gender}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">Registered</span>
|
||||
<span className="ml-auto font-medium">
|
||||
{new Date(user.accountCreated * 1000).toLocaleDateString(
|
||||
"it-IT",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">Last Login</span>
|
||||
<span className="ml-auto font-medium">
|
||||
{user.lastLogin
|
||||
? new Date(user.lastLogin * 1000).toLocaleDateString(
|
||||
"it-IT",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)
|
||||
: "Never"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">IP Register</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
||||
{user.ipRegister}
|
||||
</code>
|
||||
<Link
|
||||
href={`/admin/users?search=${user.ipRegister}`}
|
||||
title="Find other users with this IP"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 text-primary" />
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Globe className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">IP Current</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<code className="text-xs font-mono bg-muted px-2 py-0.5 rounded">
|
||||
{user.ipCurrent}
|
||||
</code>
|
||||
<Link
|
||||
href={`/admin/users?search=${user.ipCurrent}`}
|
||||
title="Find other users with this IP"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 text-primary" />
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Shield className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">2FA</span>
|
||||
<Badge
|
||||
variant={user.twoFactorConfirmed ? "default" : "outline"}
|
||||
className="ml-auto"
|
||||
>
|
||||
{user.twoFactorConfirmed ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<MonitorSmartphone className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">Machine ID</span>
|
||||
<code className="ml-auto text-xs font-mono bg-muted px-2 py-0.5 rounded truncate max-w-[180px]">
|
||||
{user.machine_id || "N/A"}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Email</span>
|
||||
<span className="ml-auto font-medium">{user.mail}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Gender</span>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{user.gender}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,37 @@
|
||||
"use client";
|
||||
|
||||
interface Badge {
|
||||
id: number;
|
||||
badgeCode: string;
|
||||
id: number;
|
||||
badgeCode: string;
|
||||
}
|
||||
|
||||
export function UserBadgesReadonly({ badges }: { badges: Badge[] }) {
|
||||
if (badges.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No badges</p>;
|
||||
}
|
||||
if (badges.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No badges</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{badges.map((badge) => (
|
||||
<div key={badge.id} className="flex items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-sm">
|
||||
<img
|
||||
src={
|
||||
process.env.NEXT_PUBLIC_BADGE_URL
|
||||
? `${process.env.NEXT_PUBLIC_BADGE_URL}/${badge.badgeCode}.gif`
|
||||
: `/swf/c_images/album1584/${badge.badgeCode}.gif`
|
||||
}
|
||||
alt={badge.badgeCode}
|
||||
className="h-5 w-5"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<span>{badge.badgeCode}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{badges.map((badge) => (
|
||||
<div
|
||||
key={badge.id}
|
||||
className="flex items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-sm"
|
||||
>
|
||||
<img
|
||||
src={
|
||||
process.env.NEXT_PUBLIC_BADGE_URL
|
||||
? `${process.env.NEXT_PUBLIC_BADGE_URL}/${badge.badgeCode}.gif`
|
||||
: `/swf/c_images/album1584/${badge.badgeCode}.gif`
|
||||
}
|
||||
alt={badge.badgeCode}
|
||||
className="h-5 w-5"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
<span>{badge.badgeCode}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Coins,
|
||||
Diamond,
|
||||
DoorOpen,
|
||||
Eye,
|
||||
Gem,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
ArrowLeftRight,
|
||||
Coins,
|
||||
Diamond,
|
||||
DoorOpen,
|
||||
Eye,
|
||||
Gem,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
|
||||
@@ -20,178 +20,198 @@ import { Button } from "@/components/ui/button";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
|
||||
interface UserHeroProps {
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
motto: string;
|
||||
look: string;
|
||||
rank: number;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
online: string;
|
||||
permission: { rankName: string };
|
||||
twoFactorConfirmed: number;
|
||||
};
|
||||
diamonds: number;
|
||||
duckets: number;
|
||||
roomCount: number;
|
||||
chatCount: number;
|
||||
tradeCount: number;
|
||||
isOnline: boolean;
|
||||
activeBan: boolean;
|
||||
mode: "show" | "edit";
|
||||
canEdit: boolean;
|
||||
/** When non-null, render the watch toggle with this initial state. */
|
||||
watching?: boolean | null;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
motto: string;
|
||||
look: string;
|
||||
rank: number;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
online: string;
|
||||
permission: { rankName: string };
|
||||
twoFactorConfirmed: number;
|
||||
};
|
||||
diamonds: number;
|
||||
duckets: number;
|
||||
roomCount: number;
|
||||
chatCount: number;
|
||||
tradeCount: number;
|
||||
isOnline: boolean;
|
||||
activeBan: boolean;
|
||||
mode: "show" | "edit";
|
||||
canEdit: boolean;
|
||||
/** When non-null, render the watch toggle with this initial state. */
|
||||
watching?: boolean | null;
|
||||
}
|
||||
|
||||
export function UserHero({
|
||||
user,
|
||||
diamonds,
|
||||
duckets,
|
||||
roomCount,
|
||||
chatCount,
|
||||
tradeCount,
|
||||
isOnline,
|
||||
activeBan,
|
||||
mode,
|
||||
canEdit,
|
||||
watching,
|
||||
user,
|
||||
diamonds,
|
||||
duckets,
|
||||
roomCount,
|
||||
chatCount,
|
||||
tradeCount,
|
||||
isOnline,
|
||||
activeBan,
|
||||
mode,
|
||||
canEdit,
|
||||
watching,
|
||||
}: UserHeroProps) {
|
||||
return (
|
||||
<>
|
||||
<AdminBreadcrumb
|
||||
items={[
|
||||
{ label: "Users", href: "/admin/users" },
|
||||
{ label: user.username },
|
||||
{ label: mode === "edit" ? "Edit" : "View" },
|
||||
]}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<AdminBreadcrumb
|
||||
items={[
|
||||
{ label: "Users", href: "/admin/users" },
|
||||
{ label: user.username },
|
||||
{ label: mode === "edit" ? "Edit" : "View" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="relative rounded-xl border bg-gradient-to-r from-card to-muted/30 p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start gap-5">
|
||||
{/* Avatar */}
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={getAvatarUrl(user.look, { headDirection: 2, direction: 2 })}
|
||||
alt={user.username}
|
||||
className="h-28 rounded-lg border bg-card shadow-sm"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<span className={`absolute -top-1 -right-1 flex h-4 w-4 ${isOnline ? "" : "opacity-50"}`}>
|
||||
{isOnline && (
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--admin-success)] opacity-75" />
|
||||
)}
|
||||
<span
|
||||
className={`relative inline-flex rounded-full h-4 w-4 border-2 border-card ${isOnline ? "bg-[var(--admin-success-subtle)]" : "bg-[var(--admin-text-muted)]"}`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative rounded-xl border bg-gradient-to-r from-card to-muted/30 p-6">
|
||||
<div className="flex flex-col sm:flex-row items-start gap-5">
|
||||
{/* Avatar */}
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={getAvatarUrl(user.look, { headDirection: 2, direction: 2 })}
|
||||
alt={user.username}
|
||||
className="h-28 rounded-lg border bg-card shadow-sm"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<span
|
||||
className={`absolute -top-1 -right-1 flex h-4 w-4 ${isOnline ? "" : "opacity-50"}`}
|
||||
>
|
||||
{isOnline && (
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[var(--admin-success)] opacity-75" />
|
||||
)}
|
||||
<span
|
||||
className={`relative inline-flex rounded-full h-4 w-4 border-2 border-card ${isOnline ? "bg-[var(--admin-success-subtle)]" : "bg-[var(--admin-text-muted)]"}`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{user.username}</h1>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
ID: {user.id}
|
||||
</Badge>
|
||||
{/* Mode switch buttons */}
|
||||
{mode === "show" && canEdit && (
|
||||
<Button variant="outline" size="sm" asChild className="ml-2">
|
||||
<Link href={`/admin/users/edit/${user.id}`}>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Edit
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<Button variant="outline" size="sm" asChild className="ml-2">
|
||||
<Link href={`/admin/users/show/${user.id}`}>
|
||||
<Eye className="mr-1.5 h-3.5 w-3.5" /> View
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{watching != null && (
|
||||
<UserWatchToggle targetUserId={user.id} username={user.username} initialWatching={watching} />
|
||||
)}
|
||||
</div>
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{user.username}
|
||||
</h1>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
ID: {user.id}
|
||||
</Badge>
|
||||
{/* Mode switch buttons */}
|
||||
{mode === "show" && canEdit && (
|
||||
<Button variant="outline" size="sm" asChild className="ml-2">
|
||||
<Link href={`/admin/users/edit/${user.id}`}>
|
||||
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Edit
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<Button variant="outline" size="sm" asChild className="ml-2">
|
||||
<Link href={`/admin/users/show/${user.id}`}>
|
||||
<Eye className="mr-1.5 h-3.5 w-3.5" /> View
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{watching != null && (
|
||||
<UserWatchToggle
|
||||
targetUserId={user.id}
|
||||
username={user.username}
|
||||
initialWatching={watching}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user.motto && <p className="text-muted-foreground italic">“{user.motto}”</p>}
|
||||
{user.motto && (
|
||||
<p className="text-muted-foreground italic">
|
||||
“{user.motto}”
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge>{user.permission.rankName || `Rank ${user.rank}`}</Badge>
|
||||
<Badge
|
||||
variant={isOnline ? "default" : "outline"}
|
||||
className={isOnline ? "bg-[var(--admin-success-subtle)]" : ""}
|
||||
>
|
||||
{isOnline ? (
|
||||
<>
|
||||
<Wifi className="mr-1 h-3 w-3" /> Online
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="mr-1 h-3 w-3" /> Offline
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
{activeBan && <Badge variant="destructive">Banned</Badge>}
|
||||
{user.twoFactorConfirmed ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-[var(--admin-success-border)] text-[var(--admin-success)]"
|
||||
>
|
||||
<Shield className="mr-1 h-3 w-3" /> 2FA
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge>{user.permission.rankName || `Rank ${user.rank}`}</Badge>
|
||||
<Badge
|
||||
variant={isOnline ? "default" : "outline"}
|
||||
className={isOnline ? "bg-[var(--admin-success-subtle)]" : ""}
|
||||
>
|
||||
{isOnline ? (
|
||||
<>
|
||||
<Wifi className="mr-1 h-3 w-3" /> Online
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="mr-1 h-3 w-3" /> Offline
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
{activeBan && <Badge variant="destructive">Banned</Badge>}
|
||||
{user.twoFactorConfirmed ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-[var(--admin-success-border)] text-[var(--admin-success)]"
|
||||
>
|
||||
<Shield className="mr-1 h-3 w-3" /> 2FA
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Currency row */}
|
||||
<div className="flex flex-wrap gap-3 pt-1">
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Coins className="h-4 w-4 text-[var(--admin-warning)]" />
|
||||
<span className="font-semibold">{user.credits.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground text-xs">credits</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Diamond className="h-4 w-4 text-[var(--admin-info)]" />
|
||||
<span className="font-semibold">{diamonds.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground text-xs">diamonds</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Sparkles className="h-4 w-4 text-[var(--admin-accent-text)]" />
|
||||
<span className="font-semibold">{duckets.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground text-xs">duckets</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Gem className="h-4 w-4 text-[var(--admin-error)]" />
|
||||
<span className="font-semibold">{user.pixels.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground text-xs">pixels</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Currency row */}
|
||||
<div className="flex flex-wrap gap-3 pt-1">
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Coins className="h-4 w-4 text-[var(--admin-warning)]" />
|
||||
<span className="font-semibold">
|
||||
{user.credits.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">credits</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Diamond className="h-4 w-4 text-[var(--admin-info)]" />
|
||||
<span className="font-semibold">
|
||||
{diamonds.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">diamonds</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Sparkles className="h-4 w-4 text-[var(--admin-accent-text)]" />
|
||||
<span className="font-semibold">
|
||||
{duckets.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">duckets</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Gem className="h-4 w-4 text-[var(--admin-error)]" />
|
||||
<span className="font-semibold">
|
||||
{user.pixels.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">pixels</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick stats cards */}
|
||||
<div className="grid grid-cols-3 gap-2 shrink-0">
|
||||
<Link
|
||||
href={`/admin/rooms?search=${user.username}`}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-center hover:shadow-md transition-shadow"
|
||||
>
|
||||
<DoorOpen className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{roomCount}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Rooms</p>
|
||||
</Link>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<MessageSquare className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{chatCount.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Messages</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<ArrowLeftRight className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{tradeCount}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Trades</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
{/* Quick stats cards */}
|
||||
<div className="grid grid-cols-3 gap-2 shrink-0">
|
||||
<Link
|
||||
href={`/admin/rooms?search=${user.username}`}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-center hover:shadow-md transition-shadow"
|
||||
>
|
||||
<DoorOpen className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{roomCount}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Rooms</p>
|
||||
</Link>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<MessageSquare className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{chatCount.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Messages</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-center">
|
||||
<ArrowLeftRight className="h-4 w-4 mx-auto text-muted-foreground" />
|
||||
<p className="text-lg font-bold">{tradeCount}</p>
|
||||
<p className="text-[10px] text-muted-foreground">Trades</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,88 +2,118 @@ import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function loadUserById(id: string) {
|
||||
const numericId = Number(id);
|
||||
const isNumeric = Number.isSafeInteger(numericId) && numericId > 0 && String(numericId) === id;
|
||||
const user = await prisma.user.findUnique({ where: isNumeric ? { id: numericId } : { username: id } });
|
||||
if (!user) notFound();
|
||||
const numericId = Number(id);
|
||||
const isNumeric =
|
||||
Number.isSafeInteger(numericId) &&
|
||||
numericId > 0 &&
|
||||
String(numericId) === id;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: isNumeric ? { id: numericId } : { username: id },
|
||||
});
|
||||
if (!user) notFound();
|
||||
|
||||
const [
|
||||
currencies,
|
||||
badges,
|
||||
bansReceived,
|
||||
permissionRows,
|
||||
roomCount,
|
||||
tradeCount,
|
||||
userRooms,
|
||||
chatLogs,
|
||||
commandLogs,
|
||||
] = await Promise.all([
|
||||
prisma.usersCurrency.findMany({ where: { userId: user.id } }),
|
||||
prisma.usersBadges.findMany({ where: { userId: user.id }, orderBy: { slotId: "asc" } }),
|
||||
prisma.$queryRaw<
|
||||
Array<{
|
||||
id: number;
|
||||
banReason: string;
|
||||
type: string;
|
||||
timestamp: number;
|
||||
banExpire: number;
|
||||
staffName: string;
|
||||
}>
|
||||
>`
|
||||
const [
|
||||
currencies,
|
||||
badges,
|
||||
bansReceived,
|
||||
permissionRows,
|
||||
roomCount,
|
||||
tradeCount,
|
||||
userRooms,
|
||||
chatLogs,
|
||||
commandLogs,
|
||||
] = await Promise.all([
|
||||
prisma.usersCurrency.findMany({ where: { userId: user.id } }),
|
||||
prisma.usersBadges.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { slotId: "asc" },
|
||||
}),
|
||||
prisma.$queryRaw<
|
||||
Array<{
|
||||
id: number;
|
||||
banReason: string;
|
||||
type: string;
|
||||
timestamp: number;
|
||||
banExpire: number;
|
||||
staffName: string;
|
||||
}>
|
||||
>`
|
||||
SELECT b.id, b.ban_reason AS banReason, b.type, b.timestamp, b.ban_expire AS banExpire,
|
||||
COALESCE(s.username, 'Unknown') AS staffName
|
||||
FROM bans b LEFT JOIN users s ON s.id = b.user_staff_id
|
||||
WHERE b.user_id = ${user.id} ORDER BY b.id DESC LIMIT 5`,
|
||||
prisma.$queryRaw<Array<{ rankName: string }>>`
|
||||
prisma.$queryRaw<Array<{ rankName: string }>>`
|
||||
SELECT COALESCE(rank_name, CONCAT('Rank ', id)) AS rankName FROM permissions WHERE id = ${user.rank} LIMIT 1`,
|
||||
prisma.rooms.count({ where: { ownerId: user.id } }),
|
||||
prisma.roomTradeLog.count({ where: { OR: [{ userOneId: user.id }, { userTwoId: user.id }] } }),
|
||||
prisma.rooms.findMany({
|
||||
where: { ownerId: user.id },
|
||||
orderBy: { users: "desc" },
|
||||
take: 20,
|
||||
select: { id: true, name: true, state: true, users: true, usersMax: true },
|
||||
}),
|
||||
prisma.$queryRaw<Array<{ id: number; roomId: number; timestamp: number; message: string }>>`
|
||||
prisma.rooms.count({ where: { ownerId: user.id } }),
|
||||
prisma.roomTradeLog.count({
|
||||
where: { OR: [{ userOneId: user.id }, { userTwoId: user.id }] },
|
||||
}),
|
||||
prisma.rooms.findMany({
|
||||
where: { ownerId: user.id },
|
||||
orderBy: { users: "desc" },
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
state: true,
|
||||
users: true,
|
||||
usersMax: true,
|
||||
},
|
||||
}),
|
||||
prisma.$queryRaw<
|
||||
Array<{ id: number; roomId: number; timestamp: number; message: string }>
|
||||
>`
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, room_id AS roomId, timestamp, message
|
||||
FROM chatlogs_room WHERE user_from_id = ${user.id} ORDER BY timestamp DESC LIMIT 20`,
|
||||
prisma.$queryRaw<
|
||||
Array<{ id: number; timestamp: number; command: string; params: string; succes: string }>
|
||||
>`
|
||||
prisma.$queryRaw<
|
||||
Array<{
|
||||
id: number;
|
||||
timestamp: number;
|
||||
command: string;
|
||||
params: string;
|
||||
succes: string;
|
||||
}>
|
||||
>`
|
||||
SELECT ROW_NUMBER() OVER (ORDER BY timestamp DESC) AS id, timestamp, command, params, succes
|
||||
FROM commandlogs WHERE user_id = ${user.id} ORDER BY timestamp DESC LIMIT 10`,
|
||||
]);
|
||||
]);
|
||||
|
||||
const diamonds = currencies.find((currency) => currency.type === 5)?.amount ?? 0;
|
||||
const duckets = currencies.find((currency) => currency.type === 0)?.amount ?? 0;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const activeBan = bansReceived.find((ban) => ban.banExpire === 0 || ban.banExpire > now);
|
||||
const decoratedUser = {
|
||||
...user,
|
||||
mail: user.mail ?? "",
|
||||
gender: String(user.gender),
|
||||
machine_id: user.machineId,
|
||||
twoFactorConfirmed: user.twoFactorConfirmedAt ? 1 : 0,
|
||||
permission: { rankName: permissionRows[0]?.rankName ?? `Rank ${user.rank}` },
|
||||
currencies,
|
||||
badges,
|
||||
bansReceived: bansReceived.map((ban) => ({
|
||||
...ban,
|
||||
staff: { username: ban.staffName },
|
||||
})),
|
||||
};
|
||||
const diamonds =
|
||||
currencies.find((currency) => currency.type === 5)?.amount ?? 0;
|
||||
const duckets =
|
||||
currencies.find((currency) => currency.type === 0)?.amount ?? 0;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const activeBan = bansReceived.find(
|
||||
(ban) => ban.banExpire === 0 || ban.banExpire > now,
|
||||
);
|
||||
const decoratedUser = {
|
||||
...user,
|
||||
mail: user.mail ?? "",
|
||||
gender: String(user.gender),
|
||||
machine_id: user.machineId,
|
||||
twoFactorConfirmed: user.twoFactorConfirmedAt ? 1 : 0,
|
||||
permission: {
|
||||
rankName: permissionRows[0]?.rankName ?? `Rank ${user.rank}`,
|
||||
},
|
||||
currencies,
|
||||
badges,
|
||||
bansReceived: bansReceived.map((ban) => ({
|
||||
...ban,
|
||||
staff: { username: ban.staffName },
|
||||
})),
|
||||
};
|
||||
|
||||
return {
|
||||
user: decoratedUser,
|
||||
diamonds,
|
||||
duckets,
|
||||
roomCount,
|
||||
chatCount: chatLogs.length,
|
||||
tradeCount,
|
||||
userRooms,
|
||||
chatLogs,
|
||||
commandLogs,
|
||||
isOnline: user.online === "1",
|
||||
activeBan,
|
||||
};
|
||||
return {
|
||||
user: decoratedUser,
|
||||
diamonds,
|
||||
duckets,
|
||||
roomCount,
|
||||
chatCount: chatLogs.length,
|
||||
tradeCount,
|
||||
userRooms,
|
||||
chatLogs,
|
||||
commandLogs,
|
||||
isOnline: user.online === "1",
|
||||
activeBan,
|
||||
};
|
||||
}
|
||||
@@ -14,114 +14,129 @@ import { UserEditForm } from "../../[id]/user-edit-form";
|
||||
import { UserQuickActions } from "../../[id]/user-quick-actions";
|
||||
import { UserRooms } from "../../[id]/user-rooms";
|
||||
|
||||
export default async function UserEditPage({ params }: { params: Promise<{ locale: string; id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.USERS_EDIT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
export default async function UserEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.USERS_EDIT, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const data = await loadUserById(id);
|
||||
const watched = await getWatchedUserIds(session.user.id);
|
||||
const data = await loadUserById(id);
|
||||
const watched = await getWatchedUserIds(session.user.id);
|
||||
|
||||
// Fetch rank list from permissions table for the rank dropdown
|
||||
const rawRanks: { id: number; rank_name: string }[] = await prisma.$queryRaw(
|
||||
Prisma.sql`SELECT id, rank_name FROM permissions ORDER BY id ASC`,
|
||||
);
|
||||
const ranks = rawRanks.map((r) => ({
|
||||
id: Number(r.id),
|
||||
name: String(r.rank_name || `Rank ${r.id}`),
|
||||
}));
|
||||
// Fetch rank list from permissions table for the rank dropdown
|
||||
const rawRanks: { id: number; rank_name: string }[] = await prisma.$queryRaw(
|
||||
Prisma.sql`SELECT id, rank_name FROM permissions ORDER BY id ASC`,
|
||||
);
|
||||
const ranks = rawRanks.map((r) => ({
|
||||
id: Number(r.id),
|
||||
name: String(r.rank_name || `Rank ${r.id}`),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<UserHero
|
||||
user={data.user}
|
||||
diamonds={data.diamonds}
|
||||
duckets={data.duckets}
|
||||
roomCount={data.roomCount}
|
||||
chatCount={data.chatCount}
|
||||
tradeCount={data.tradeCount}
|
||||
isOnline={data.isOnline}
|
||||
activeBan={!!data.activeBan}
|
||||
mode="edit"
|
||||
canEdit={true}
|
||||
watching={watched.has(data.user.id)}
|
||||
/>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<UserHero
|
||||
user={data.user}
|
||||
diamonds={data.diamonds}
|
||||
duckets={data.duckets}
|
||||
roomCount={data.roomCount}
|
||||
chatCount={data.chatCount}
|
||||
tradeCount={data.tradeCount}
|
||||
isOnline={data.isOnline}
|
||||
activeBan={!!data.activeBan}
|
||||
mode="edit"
|
||||
canEdit={true}
|
||||
watching={watched.has(data.user.id)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_320px] xl:grid-cols-[1fr_360px]">
|
||||
<Tabs defaultValue="edit" className="space-y-4 min-w-0">
|
||||
<TabsList className="flex-wrap">
|
||||
<TabsTrigger value="edit">Edit Profile</TabsTrigger>
|
||||
<TabsTrigger value="bans">
|
||||
Bans
|
||||
{data.activeBan && <span className="ml-1.5 flex h-2 w-2 rounded-full bg-destructive" />}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="badges">Badges ({data.user.badges.length})</TabsTrigger>
|
||||
<TabsTrigger value="rooms">Rooms ({data.roomCount})</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
<TabsTrigger value="info">Account Info</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_320px] xl:grid-cols-[1fr_360px]">
|
||||
<Tabs defaultValue="edit" className="space-y-4 min-w-0">
|
||||
<TabsList className="flex-wrap">
|
||||
<TabsTrigger value="edit">Edit Profile</TabsTrigger>
|
||||
<TabsTrigger value="bans">
|
||||
Bans
|
||||
{data.activeBan && (
|
||||
<span className="ml-1.5 flex h-2 w-2 rounded-full bg-destructive" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="badges">
|
||||
Badges ({data.user.badges.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rooms">Rooms ({data.roomCount})</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
<TabsTrigger value="info">Account Info</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="edit">
|
||||
<UserEditForm
|
||||
user={{
|
||||
id: data.user.id,
|
||||
username: data.user.username,
|
||||
mail: data.user.mail,
|
||||
rank: data.user.rank,
|
||||
motto: data.user.motto,
|
||||
credits: data.user.credits,
|
||||
pixels: data.user.pixels,
|
||||
diamonds: data.diamonds,
|
||||
duckets: data.duckets,
|
||||
}}
|
||||
ranks={ranks}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="edit">
|
||||
<UserEditForm
|
||||
user={{
|
||||
id: data.user.id,
|
||||
username: data.user.username,
|
||||
mail: data.user.mail,
|
||||
rank: data.user.rank,
|
||||
motto: data.user.motto,
|
||||
credits: data.user.credits,
|
||||
pixels: data.user.pixels,
|
||||
diamonds: data.diamonds,
|
||||
duckets: data.duckets,
|
||||
}}
|
||||
ranks={ranks}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bans">
|
||||
<UserBanSection
|
||||
userId={data.user.id}
|
||||
username={data.user.username}
|
||||
bans={data.user.bansReceived.map((b) => ({
|
||||
id: b.id,
|
||||
reason: b.banReason,
|
||||
type: b.type,
|
||||
staffName: b.staff.username,
|
||||
timestamp: b.timestamp,
|
||||
banExpire: b.banExpire,
|
||||
}))}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="bans">
|
||||
<UserBanSection
|
||||
userId={data.user.id}
|
||||
username={data.user.username}
|
||||
bans={data.user.bansReceived.map((b) => ({
|
||||
id: b.id,
|
||||
reason: b.banReason,
|
||||
type: b.type,
|
||||
staffName: b.staff.username,
|
||||
timestamp: b.timestamp,
|
||||
banExpire: b.banExpire,
|
||||
}))}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="badges">
|
||||
<UserBadgesSection
|
||||
userId={data.user.id}
|
||||
badges={data.user.badges.map((b) => ({
|
||||
id: b.id,
|
||||
badgeCode: b.badgeCode,
|
||||
}))}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="badges">
|
||||
<UserBadgesSection
|
||||
userId={data.user.id}
|
||||
badges={data.user.badges.map((b) => ({
|
||||
id: b.id,
|
||||
badgeCode: b.badgeCode,
|
||||
}))}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rooms">
|
||||
<UserRooms rooms={data.userRooms} />
|
||||
</TabsContent>
|
||||
<TabsContent value="rooms">
|
||||
<UserRooms rooms={data.userRooms} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
<UserActivityLog chatLogs={data.chatLogs} commandLogs={data.commandLogs} />
|
||||
</TabsContent>
|
||||
<TabsContent value="activity">
|
||||
<UserActivityLog
|
||||
chatLogs={data.chatLogs}
|
||||
commandLogs={data.commandLogs}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="info">
|
||||
<UserAccountInfo user={data.user} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="info">
|
||||
<UserAccountInfo user={data.user} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<aside className="lg:sticky lg:top-4 lg:self-start">
|
||||
<UserQuickActions userId={data.user.id} username={data.user.username} isOnline={data.isOnline} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<aside className="lg:sticky lg:top-4 lg:self-start">
|
||||
<UserQuickActions
|
||||
userId={data.user.id}
|
||||
username={data.user.username}
|
||||
isOnline={data.isOnline}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,49 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function UsersLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<Skeleton className="h-5 w-56 mt-2" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-28" />
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<Skeleton className="h-5 w-56 mt-2" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-28" />
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-10 w-[100px]" />
|
||||
</div>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-10 w-24" />
|
||||
<Skeleton className="h-10 w-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="flex gap-8">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-4 w-20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-8 px-4 py-3 border-b last:border-0">
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-5 w-14 rounded-full" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-5 w-14 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="flex gap-8">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-4 w-20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-8 px-4 py-3 border-b last:border-0"
|
||||
>
|
||||
<Skeleton className="h-4 w-10" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-36" />
|
||||
<Skeleton className="h-5 w-14 rounded-full" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-5 w-14 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +1,124 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { detectMultiAccounts, type MultiAccountCluster } from "@/actions/multi-account-detect";
|
||||
import {
|
||||
detectMultiAccounts,
|
||||
type MultiAccountCluster,
|
||||
} from "@/actions/multi-account-detect";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
|
||||
export default async function MultiAccountsPage({
|
||||
searchParams,
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) redirect("/admin");
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank))
|
||||
redirect("/admin");
|
||||
|
||||
const params = await searchParams;
|
||||
const minAccounts = Math.max(2, Number(params.min) || 2);
|
||||
const limit = Math.min(200, Number(params.limit) || 50);
|
||||
const params = await searchParams;
|
||||
const minAccounts = Math.max(2, Number(params.min) || 2);
|
||||
const limit = Math.min(200, Number(params.limit) || 50);
|
||||
|
||||
const result = await detectMultiAccounts({ minAccounts, limit });
|
||||
const clusters: MultiAccountCluster[] = result.ok
|
||||
? ((result.data as { clusters: MultiAccountCluster[] }).clusters ?? [])
|
||||
: [];
|
||||
const result = await detectMultiAccounts({ minAccounts, limit });
|
||||
const clusters: MultiAccountCluster[] = result.ok
|
||||
? ((result.data as { clusters: MultiAccountCluster[] }).clusters ?? [])
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Multi-account detector</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Clusters of users sharing a registration IP, current IP, or machine ID. Detection is passive — no
|
||||
AI, just a GROUP BY with index support.
|
||||
</p>
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Multi-account detector
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Clusters of users sharing a registration IP, current IP, or machine
|
||||
ID. Detection is passive — no AI, just a GROUP BY with index support.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-wrap items-end gap-3" action="">
|
||||
<label className="text-sm">
|
||||
<span className="block text-muted-foreground">Min accounts per cluster</span>
|
||||
<input
|
||||
type="number"
|
||||
name="min"
|
||||
defaultValue={minAccounts}
|
||||
min={2}
|
||||
max={50}
|
||||
className="mt-1 h-9 w-24 rounded-md border bg-background px-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="block text-muted-foreground">Result limit</span>
|
||||
<input
|
||||
type="number"
|
||||
name="limit"
|
||||
defaultValue={limit}
|
||||
min={1}
|
||||
max={200}
|
||||
className="mt-1 h-9 w-24 rounded-md border bg-background px-2"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="h-9 rounded-md bg-primary px-4 text-sm text-primary-foreground">
|
||||
Refresh
|
||||
</button>
|
||||
</form>
|
||||
<form className="flex flex-wrap items-end gap-3" action="">
|
||||
<label className="text-sm">
|
||||
<span className="block text-muted-foreground">
|
||||
Min accounts per cluster
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
name="min"
|
||||
defaultValue={minAccounts}
|
||||
min={2}
|
||||
max={50}
|
||||
className="mt-1 h-9 w-24 rounded-md border bg-background px-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="block text-muted-foreground">Result limit</span>
|
||||
<input
|
||||
type="number"
|
||||
name="limit"
|
||||
defaultValue={limit}
|
||||
min={1}
|
||||
max={200}
|
||||
className="mt-1 h-9 w-24 rounded-md border bg-background px-2"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="h-9 rounded-md bg-primary px-4 text-sm text-primary-foreground"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{clusters.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center text-muted-foreground">
|
||||
No clusters found at the current thresholds.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{clusters.map((cluster) => (
|
||||
<Card key={cluster.key}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">
|
||||
<Badge variant="secondary" className="mr-2">
|
||||
{cluster.label}
|
||||
</Badge>
|
||||
{cluster.accountCount} accounts share this identifier
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cluster.accounts.map((acc) => (
|
||||
<Link
|
||||
key={acc.id}
|
||||
href={`/admin/users/show/${acc.id}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border bg-background px-2 py-1 text-sm hover:bg-accent"
|
||||
>
|
||||
<span className="font-medium">{acc.username}</span>
|
||||
<span className="text-xs text-muted-foreground">#{acc.id}</span>
|
||||
{acc.rank >= 5 && <Badge variant="outline">staff</Badge>}
|
||||
{acc.online === "1" && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--admin-success-subtle)]" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
{cluster.accountCount > cluster.accounts.length && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
… and {cluster.accountCount - cluster.accounts.length} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{clusters.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center text-muted-foreground">
|
||||
No clusters found at the current thresholds.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{clusters.map((cluster) => (
|
||||
<Card key={cluster.key}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">
|
||||
<Badge variant="secondary" className="mr-2">
|
||||
{cluster.label}
|
||||
</Badge>
|
||||
{cluster.accountCount} accounts share this identifier
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cluster.accounts.map((acc) => (
|
||||
<Link
|
||||
key={acc.id}
|
||||
href={`/admin/users/show/${acc.id}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border bg-background px-2 py-1 text-sm hover:bg-accent"
|
||||
>
|
||||
<span className="font-medium">{acc.username}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
#{acc.id}
|
||||
</span>
|
||||
{acc.rank >= 5 && <Badge variant="outline">staff</Badge>}
|
||||
{acc.online === "1" && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[var(--admin-success-subtle)]" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
{cluster.accountCount > cluster.accounts.length && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
… and {cluster.accountCount - cluster.accounts.length}{" "}
|
||||
more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,69 +4,71 @@ import { CreateUserDialog } from "./_components/create-user-dialog";
|
||||
import { UsersTable } from "./users-table";
|
||||
|
||||
export default async function UsersPage({
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.USERS_VIEW,
|
||||
model: "user",
|
||||
searchFields: ["username", "mail", "ipRegister", "ipCurrent"],
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
mail: true,
|
||||
rank: true,
|
||||
credits: true,
|
||||
pixels: true,
|
||||
online: true,
|
||||
motto: true,
|
||||
look: true,
|
||||
ipRegister: true,
|
||||
accountCreated: true,
|
||||
lastLogin: true,
|
||||
},
|
||||
filterMap: {
|
||||
filter_rank: (v) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
// For rank 7 the preset means "super admins" — match ≥ 7 instead of equals.
|
||||
return n >= 7 ? { rank: { gte: 7 } } : { rank: n };
|
||||
},
|
||||
filter_online: (v) => ({ online: v }),
|
||||
},
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
);
|
||||
const result = await fetchAdminList(
|
||||
{
|
||||
permission: PERMS.USERS_VIEW,
|
||||
model: "user",
|
||||
searchFields: ["username", "mail", "ipRegister", "ipCurrent"],
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
mail: true,
|
||||
rank: true,
|
||||
credits: true,
|
||||
pixels: true,
|
||||
online: true,
|
||||
motto: true,
|
||||
look: true,
|
||||
ipRegister: true,
|
||||
accountCreated: true,
|
||||
lastLogin: true,
|
||||
},
|
||||
filterMap: {
|
||||
filter_rank: (v) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
// For rank 7 the preset means "super admins" — match ≥ 7 instead of equals.
|
||||
return n >= 7 ? { rank: { gte: 7 } } : { rank: n };
|
||||
},
|
||||
filter_online: (v) => ({ online: v }),
|
||||
},
|
||||
},
|
||||
paramsPromise,
|
||||
searchParams,
|
||||
);
|
||||
|
||||
const canEdit = canAccess(result.permissions, PERMS.USERS_EDIT, result.rank);
|
||||
const canBan = canAccess(result.permissions, PERMS.USERS_BAN, result.rank);
|
||||
const canEdit = canAccess(result.permissions, PERMS.USERS_EDIT, result.rank);
|
||||
const canBan = canAccess(result.permissions, PERMS.USERS_BAN, result.rank);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Users</h1>
|
||||
<p className="text-muted-foreground">{result.total.toLocaleString()} users total</p>
|
||||
</div>
|
||||
{canEdit && <CreateUserDialog sessionRank={result.rank} />}
|
||||
</div>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Users</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{result.total.toLocaleString()} users total
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && <CreateUserDialog sessionRank={result.rank} />}
|
||||
</div>
|
||||
|
||||
<UsersTable
|
||||
data={{
|
||||
rows: result.rows,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
lastPage: result.lastPage,
|
||||
}}
|
||||
canEdit={canEdit}
|
||||
canBan={canBan}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
<UsersTable
|
||||
data={{
|
||||
rows: result.rows,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
perPage: result.perPage,
|
||||
lastPage: result.lastPage,
|
||||
}}
|
||||
canEdit={canEdit}
|
||||
canBan={canBan}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,68 +10,89 @@ import { UserActivityLog } from "../../[id]/user-activity-log";
|
||||
import { UserQuickActions } from "../../[id]/user-quick-actions";
|
||||
import { UserRooms } from "../../[id]/user-rooms";
|
||||
|
||||
export default async function UserShowPage({ params }: { params: Promise<{ locale: string; id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
export default async function UserShowPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.USERS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
|
||||
const data = await loadUserById(id);
|
||||
const watched = await getWatchedUserIds(session.user.id);
|
||||
const canEdit = canAccess(permissions, PERMS.USERS_EDIT, session.user.rank);
|
||||
const data = await loadUserById(id);
|
||||
const watched = await getWatchedUserIds(session.user.id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<UserHero
|
||||
user={data.user}
|
||||
diamonds={data.diamonds}
|
||||
duckets={data.duckets}
|
||||
roomCount={data.roomCount}
|
||||
chatCount={data.chatCount}
|
||||
tradeCount={data.tradeCount}
|
||||
isOnline={data.isOnline}
|
||||
activeBan={!!data.activeBan}
|
||||
mode="show"
|
||||
canEdit={canEdit}
|
||||
watching={watched.has(data.user.id)}
|
||||
/>
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<UserHero
|
||||
user={data.user}
|
||||
diamonds={data.diamonds}
|
||||
duckets={data.duckets}
|
||||
roomCount={data.roomCount}
|
||||
chatCount={data.chatCount}
|
||||
tradeCount={data.tradeCount}
|
||||
isOnline={data.isOnline}
|
||||
activeBan={!!data.activeBan}
|
||||
mode="show"
|
||||
canEdit={canEdit}
|
||||
watching={watched.has(data.user.id)}
|
||||
/>
|
||||
|
||||
<div className={canEdit ? "grid gap-6 lg:grid-cols-[1fr_320px] xl:grid-cols-[1fr_360px]" : undefined}>
|
||||
<Tabs defaultValue="info" className="space-y-4 min-w-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="info">Account Info</TabsTrigger>
|
||||
<TabsTrigger value="badges">Badges ({data.user.badges.length})</TabsTrigger>
|
||||
<TabsTrigger value="rooms">Rooms ({data.roomCount})</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
<div
|
||||
className={
|
||||
canEdit
|
||||
? "grid gap-6 lg:grid-cols-[1fr_320px] xl:grid-cols-[1fr_360px]"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Tabs defaultValue="info" className="space-y-4 min-w-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="info">Account Info</TabsTrigger>
|
||||
<TabsTrigger value="badges">
|
||||
Badges ({data.user.badges.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rooms">Rooms ({data.roomCount})</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="info">
|
||||
<UserAccountInfo user={data.user} />
|
||||
</TabsContent>
|
||||
<TabsContent value="info">
|
||||
<UserAccountInfo user={data.user} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="badges">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">Badges ({data.user.badges.length})</h3>
|
||||
<UserBadgesReadonly badges={data.user.badges} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="badges">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">
|
||||
Badges ({data.user.badges.length})
|
||||
</h3>
|
||||
<UserBadgesReadonly badges={data.user.badges} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rooms">
|
||||
<UserRooms rooms={data.userRooms} />
|
||||
</TabsContent>
|
||||
<TabsContent value="rooms">
|
||||
<UserRooms rooms={data.userRooms} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
<UserActivityLog chatLogs={data.chatLogs} commandLogs={data.commandLogs} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<TabsContent value="activity">
|
||||
<UserActivityLog
|
||||
chatLogs={data.chatLogs}
|
||||
commandLogs={data.commandLogs}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{canEdit && (
|
||||
<aside className="lg:sticky lg:top-4 lg:self-start">
|
||||
<UserQuickActions userId={data.user.id} username={data.user.username} isOnline={data.isOnline} />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{canEdit && (
|
||||
<aside className="lg:sticky lg:top-4 lg:self-start">
|
||||
<UserQuickActions
|
||||
userId={data.user.id}
|
||||
username={data.user.username}
|
||||
isOnline={data.isOnline}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+467
-377
@@ -4,421 +4,511 @@ import { Eye, Pencil, Wifi, WifiOff } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { bulkBan, bulkGiveBadge, bulkGiveCurrency, bulkUnban } from "@/actions/bulk-users";
|
||||
import {
|
||||
bulkBan,
|
||||
bulkGiveBadge,
|
||||
bulkGiveCurrency,
|
||||
bulkUnban,
|
||||
} from "@/actions/bulk-users";
|
||||
import { DataTable } from "@/components/admin/data-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getAvatarUrl } from "@/lib/imager";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types";
|
||||
|
||||
interface UserRow {
|
||||
id: number;
|
||||
username: string;
|
||||
mail: string;
|
||||
rank: number;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
online: string;
|
||||
motto: string;
|
||||
look: string;
|
||||
ipRegister: string;
|
||||
accountCreated: number;
|
||||
lastLogin: number | null;
|
||||
id: number;
|
||||
username: string;
|
||||
mail: string;
|
||||
rank: number;
|
||||
credits: number;
|
||||
pixels: number;
|
||||
online: string;
|
||||
motto: string;
|
||||
look: string;
|
||||
ipRegister: string;
|
||||
accountCreated: number;
|
||||
lastLogin: number | null;
|
||||
}
|
||||
|
||||
const RANK_LABELS: Record<number, string> = {
|
||||
1: "User",
|
||||
2: "Bronze",
|
||||
3: "Silver",
|
||||
4: "Gold",
|
||||
5: "Moderator",
|
||||
6: "Admin",
|
||||
7: "Super Admin",
|
||||
1: "User",
|
||||
2: "Bronze",
|
||||
3: "Silver",
|
||||
4: "Gold",
|
||||
5: "Moderator",
|
||||
6: "Admin",
|
||||
7: "Super Admin",
|
||||
};
|
||||
|
||||
function getColumns(canEdit: boolean): DataTableColumn<UserRow>[] {
|
||||
const cols: DataTableColumn<UserRow>[] = [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{
|
||||
key: "username",
|
||||
label: "User",
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<Link href={`/admin/users/show/${row.id}`} className="flex items-center gap-2.5 hover:opacity-80">
|
||||
<img
|
||||
src={getAvatarUrl(row.look, { size: "s", headOnly: true, headDirection: 2 })}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium block">{row.username}</span>
|
||||
{row.motto && (
|
||||
<span className="text-xs text-muted-foreground truncate block max-w-[200px]">{row.motto}</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ key: "mail", label: "Email", sortable: true },
|
||||
{
|
||||
key: "rank",
|
||||
label: "Rank",
|
||||
sortable: true,
|
||||
filterKey: "filter_rank",
|
||||
filterOptions: [
|
||||
{ label: "User (1)", value: "1" },
|
||||
{ label: "Bronze (2)", value: "2" },
|
||||
{ label: "Silver (3)", value: "3" },
|
||||
{ label: "Gold (4)", value: "4" },
|
||||
{ label: "Moderator (5)", value: "5" },
|
||||
{ label: "Admin (6)", value: "6" },
|
||||
{ label: "Super Admin (7+)", value: "7" },
|
||||
],
|
||||
render: (value) => {
|
||||
const r = Number(value);
|
||||
return (
|
||||
<Badge variant={r >= 7 ? "default" : r >= 5 ? "secondary" : "outline"}>
|
||||
{RANK_LABELS[r] || `Rank ${r}`}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "credits",
|
||||
label: "Credits",
|
||||
sortable: true,
|
||||
render: (value) => <span className="tabular-nums">{Number(value).toLocaleString()}</span>,
|
||||
},
|
||||
{
|
||||
key: "online",
|
||||
label: "Status",
|
||||
filterKey: "filter_online",
|
||||
filterOptions: [
|
||||
{ label: "Online", value: "1" },
|
||||
{ label: "Offline", value: "0" },
|
||||
],
|
||||
render: (value) =>
|
||||
value === "1" ? (
|
||||
<span className="flex items-center gap-1.5 text-[var(--admin-success)]">
|
||||
<Wifi className="h-3.5 w-3.5" /> Online
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<WifiOff className="h-3.5 w-3.5" /> Offline
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "accountCreated",
|
||||
label: "Registered",
|
||||
sortable: true,
|
||||
render: (value) => {
|
||||
const ts = Number(value);
|
||||
if (!ts) return "-";
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(ts * 1000).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "",
|
||||
render: (_, row) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" asChild title="View">
|
||||
<Link href={`/admin/users/show/${row.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="icon" asChild title="Edit">
|
||||
<Link href={`/admin/users/edit/${row.id}`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
const cols: DataTableColumn<UserRow>[] = [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{
|
||||
key: "username",
|
||||
label: "User",
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<Link
|
||||
href={`/admin/users/show/${row.id}`}
|
||||
className="flex items-center gap-2.5 hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={getAvatarUrl(row.look, {
|
||||
size: "s",
|
||||
headOnly: true,
|
||||
headDirection: 2,
|
||||
})}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium block">{row.username}</span>
|
||||
{row.motto && (
|
||||
<span className="text-xs text-muted-foreground truncate block max-w-[200px]">
|
||||
{row.motto}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ key: "mail", label: "Email", sortable: true },
|
||||
{
|
||||
key: "rank",
|
||||
label: "Rank",
|
||||
sortable: true,
|
||||
filterKey: "filter_rank",
|
||||
filterOptions: [
|
||||
{ label: "User (1)", value: "1" },
|
||||
{ label: "Bronze (2)", value: "2" },
|
||||
{ label: "Silver (3)", value: "3" },
|
||||
{ label: "Gold (4)", value: "4" },
|
||||
{ label: "Moderator (5)", value: "5" },
|
||||
{ label: "Admin (6)", value: "6" },
|
||||
{ label: "Super Admin (7+)", value: "7" },
|
||||
],
|
||||
render: (value) => {
|
||||
const r = Number(value);
|
||||
return (
|
||||
<Badge
|
||||
variant={r >= 7 ? "default" : r >= 5 ? "secondary" : "outline"}
|
||||
>
|
||||
{RANK_LABELS[r] || `Rank ${r}`}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "credits",
|
||||
label: "Credits",
|
||||
sortable: true,
|
||||
render: (value) => (
|
||||
<span className="tabular-nums">{Number(value).toLocaleString()}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "online",
|
||||
label: "Status",
|
||||
filterKey: "filter_online",
|
||||
filterOptions: [
|
||||
{ label: "Online", value: "1" },
|
||||
{ label: "Offline", value: "0" },
|
||||
],
|
||||
render: (value) =>
|
||||
value === "1" ? (
|
||||
<span className="flex items-center gap-1.5 text-[var(--admin-success)]">
|
||||
<Wifi className="h-3.5 w-3.5" /> Online
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<WifiOff className="h-3.5 w-3.5" /> Offline
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "accountCreated",
|
||||
label: "Registered",
|
||||
sortable: true,
|
||||
render: (value) => {
|
||||
const ts = Number(value);
|
||||
if (!ts) return "-";
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(ts * 1000).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "",
|
||||
render: (_, row) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" asChild title="View">
|
||||
<Link href={`/admin/users/show/${row.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="icon" asChild title="Edit">
|
||||
<Link href={`/admin/users/edit/${row.id}`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return cols;
|
||||
return cols;
|
||||
}
|
||||
|
||||
interface UsersTableProps {
|
||||
data: PaginatedResult<UserRow>;
|
||||
canEdit: boolean;
|
||||
canBan: boolean;
|
||||
data: PaginatedResult<UserRow>;
|
||||
canEdit: boolean;
|
||||
canBan: boolean;
|
||||
}
|
||||
|
||||
type PendingDialog =
|
||||
| { kind: "ban"; ids: number[] }
|
||||
| { kind: "give-currency"; ids: number[] }
|
||||
| { kind: "give-badge"; ids: number[] }
|
||||
| null;
|
||||
| { kind: "ban"; ids: number[] }
|
||||
| { kind: "give-currency"; ids: number[] }
|
||||
| { kind: "give-badge"; ids: number[] }
|
||||
| null;
|
||||
|
||||
export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
|
||||
const [pending, setPending] = useState<PendingDialog>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [banReason, setBanReason] = useState("Bulk ban");
|
||||
const [banDuration, setBanDuration] = useState("-1");
|
||||
const [currencyType, setCurrencyType] = useState<"credits" | "pixels" | "points">("credits");
|
||||
const [currencyAmount, setCurrencyAmount] = useState("100");
|
||||
const [badgeCode, setBadgeCode] = useState("");
|
||||
const [pending, setPending] = useState<PendingDialog>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [banReason, setBanReason] = useState("Bulk ban");
|
||||
const [banDuration, setBanDuration] = useState("-1");
|
||||
const [currencyType, setCurrencyType] = useState<
|
||||
"credits" | "pixels" | "points"
|
||||
>("credits");
|
||||
const [currencyAmount, setCurrencyAmount] = useState("100");
|
||||
const [badgeCode, setBadgeCode] = useState("");
|
||||
|
||||
function reportResult(label: string, ok: number, total: number, failures = 0) {
|
||||
if (ok > 0) toast.success(`${label}: ${ok}/${total}${failures ? ` · ${failures} failed` : ""}`);
|
||||
if (failures > 0 && ok === 0) toast.error(`${label} failed for ${failures} users`);
|
||||
}
|
||||
function reportResult(
|
||||
label: string,
|
||||
ok: number,
|
||||
total: number,
|
||||
failures = 0,
|
||||
) {
|
||||
if (ok > 0)
|
||||
toast.success(
|
||||
`${label}: ${ok}/${total}${failures ? ` · ${failures} failed` : ""}`,
|
||||
);
|
||||
if (failures > 0 && ok === 0)
|
||||
toast.error(`${label} failed for ${failures} users`);
|
||||
}
|
||||
|
||||
async function handleBulkAction(action: string, ids: (number | string)[]) {
|
||||
const numericIds = ids.map((i) => Number(i));
|
||||
async function handleBulkAction(action: string, ids: (number | string)[]) {
|
||||
const numericIds = ids.map((i) => Number(i));
|
||||
|
||||
if (action === "unban") {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkUnban({ userIds: numericIds });
|
||||
if (result.ok) {
|
||||
const data = result.data as { unbanned: number; total: number };
|
||||
reportResult("Unbanned", data.unbanned, data.total);
|
||||
} else toast.error(result.error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === "unban") {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkUnban({ userIds: numericIds });
|
||||
if (result.ok) {
|
||||
const data = result.data as { unbanned: number; total: number };
|
||||
reportResult("Unbanned", data.unbanned, data.total);
|
||||
} else toast.error(result.error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "ban") {
|
||||
setPending({ kind: "ban", ids: numericIds });
|
||||
return;
|
||||
}
|
||||
if (action === "ban") {
|
||||
setPending({ kind: "ban", ids: numericIds });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "give-currency") {
|
||||
setPending({ kind: "give-currency", ids: numericIds });
|
||||
return;
|
||||
}
|
||||
if (action === "give-currency") {
|
||||
setPending({ kind: "give-currency", ids: numericIds });
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "give-badge") {
|
||||
setPending({ kind: "give-badge", ids: numericIds });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (action === "give-badge") {
|
||||
setPending({ kind: "give-badge", ids: numericIds });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmBan() {
|
||||
if (pending?.kind !== "ban") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkBan({
|
||||
userIds: pending.ids,
|
||||
reason: banReason,
|
||||
duration: Number(banDuration),
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data as { banned: number };
|
||||
reportResult("Banned", data.banned, pending.ids.length);
|
||||
} else toast.error(result.error);
|
||||
setPending(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function confirmBan() {
|
||||
if (pending?.kind !== "ban") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkBan({
|
||||
userIds: pending.ids,
|
||||
reason: banReason,
|
||||
duration: Number(banDuration),
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data as { banned: number };
|
||||
reportResult("Banned", data.banned, pending.ids.length);
|
||||
} else toast.error(result.error);
|
||||
setPending(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmGiveCurrency() {
|
||||
if (pending?.kind !== "give-currency") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkGiveCurrency({
|
||||
userIds: pending.ids,
|
||||
amount: Number(currencyAmount),
|
||||
type: currencyType,
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data as {
|
||||
given: number;
|
||||
total: number;
|
||||
failedIds: { userId: number; reason: string }[];
|
||||
};
|
||||
reportResult(
|
||||
`Given ${currencyAmount} ${currencyType}`,
|
||||
data.given,
|
||||
data.total,
|
||||
data.failedIds.length,
|
||||
);
|
||||
} else toast.error(result.error);
|
||||
setPending(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function confirmGiveCurrency() {
|
||||
if (pending?.kind !== "give-currency") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkGiveCurrency({
|
||||
userIds: pending.ids,
|
||||
amount: Number(currencyAmount),
|
||||
type: currencyType,
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data as {
|
||||
given: number;
|
||||
total: number;
|
||||
failedIds: { userId: number; reason: string }[];
|
||||
};
|
||||
reportResult(
|
||||
`Given ${currencyAmount} ${currencyType}`,
|
||||
data.given,
|
||||
data.total,
|
||||
data.failedIds.length,
|
||||
);
|
||||
} else toast.error(result.error);
|
||||
setPending(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmGiveBadge() {
|
||||
if (pending?.kind !== "give-badge") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkGiveBadge({
|
||||
userIds: pending.ids,
|
||||
badgeCode: badgeCode.trim().toUpperCase(),
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data as {
|
||||
given: number;
|
||||
total: number;
|
||||
failedIds: { userId: number; reason: string }[];
|
||||
};
|
||||
reportResult(`Given badge ${badgeCode}`, data.given, data.total, data.failedIds.length);
|
||||
} else toast.error(result.error);
|
||||
setPending(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function confirmGiveBadge() {
|
||||
if (pending?.kind !== "give-badge") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bulkGiveBadge({
|
||||
userIds: pending.ids,
|
||||
badgeCode: badgeCode.trim().toUpperCase(),
|
||||
});
|
||||
if (result.ok) {
|
||||
const data = result.data as {
|
||||
given: number;
|
||||
total: number;
|
||||
failedIds: { userId: number; reason: string }[];
|
||||
};
|
||||
reportResult(
|
||||
`Given badge ${badgeCode}`,
|
||||
data.given,
|
||||
data.total,
|
||||
data.failedIds.length,
|
||||
);
|
||||
} else toast.error(result.error);
|
||||
setPending(null);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const bulkActions: { label: string; value: string; variant?: "destructive" | "default" }[] = [];
|
||||
if (canBan) {
|
||||
bulkActions.push({ label: "Ban", value: "ban", variant: "destructive" });
|
||||
bulkActions.push({ label: "Unban", value: "unban" });
|
||||
}
|
||||
if (canEdit) {
|
||||
bulkActions.push({ label: "Give credits", value: "give-currency" });
|
||||
bulkActions.push({ label: "Give badge", value: "give-badge" });
|
||||
}
|
||||
const bulkActions: {
|
||||
label: string;
|
||||
value: string;
|
||||
variant?: "destructive" | "default";
|
||||
}[] = [];
|
||||
if (canBan) {
|
||||
bulkActions.push({ label: "Ban", value: "ban", variant: "destructive" });
|
||||
bulkActions.push({ label: "Unban", value: "unban" });
|
||||
}
|
||||
if (canEdit) {
|
||||
bulkActions.push({ label: "Give credits", value: "give-currency" });
|
||||
bulkActions.push({ label: "Give badge", value: "give-badge" });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={getColumns(canEdit)}
|
||||
searchPlaceholder="Search by username, email or IP..."
|
||||
presets={[
|
||||
{ label: "Online now", params: { filter_online: "1" } },
|
||||
{ label: "Staff (rank ≥ 5)", params: { filter_rank: "5" } },
|
||||
{ label: "Super admins", params: { filter_rank: "7" } },
|
||||
]}
|
||||
bulkActions={bulkActions.length > 0 ? bulkActions : undefined}
|
||||
onBulkAction={bulkActions.length > 0 ? handleBulkAction : undefined}
|
||||
exportUrl={canEdit ? "/api/admin/export?type=users" : undefined}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={getColumns(canEdit)}
|
||||
searchPlaceholder="Search by username, email or IP..."
|
||||
presets={[
|
||||
{ label: "Online now", params: { filter_online: "1" } },
|
||||
{ label: "Staff (rank ≥ 5)", params: { filter_rank: "5" } },
|
||||
{ label: "Super admins", params: { filter_rank: "7" } },
|
||||
]}
|
||||
bulkActions={bulkActions.length > 0 ? bulkActions : undefined}
|
||||
onBulkAction={bulkActions.length > 0 ? handleBulkAction : undefined}
|
||||
exportUrl={canEdit ? "/api/admin/export?type=users" : undefined}
|
||||
/>
|
||||
|
||||
<Dialog open={pending?.kind === "ban"} onOpenChange={(o) => !o && setPending(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ban {pending?.kind === "ban" ? pending.ids.length : 0} users</DialogTitle>
|
||||
<DialogDescription>Permanent ban: leave duration at -1.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="ban-reason">Reason</Label>
|
||||
<Input id="ban-reason" value={banReason} onChange={(e) => setBanReason(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ban-duration">Duration (seconds, -1 = permanent)</Label>
|
||||
<Input
|
||||
id="ban-duration"
|
||||
type="number"
|
||||
value={banDuration}
|
||||
onChange={(e) => setBanDuration(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPending(null)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmBan} disabled={busy || !banReason}>
|
||||
Ban
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={pending?.kind === "ban"}
|
||||
onOpenChange={(o) => !o && setPending(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Ban {pending?.kind === "ban" ? pending.ids.length : 0} users
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Permanent ban: leave duration at -1.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="ban-reason">Reason</Label>
|
||||
<Input
|
||||
id="ban-reason"
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ban-duration">
|
||||
Duration (seconds, -1 = permanent)
|
||||
</Label>
|
||||
<Input
|
||||
id="ban-duration"
|
||||
type="number"
|
||||
value={banDuration}
|
||||
onChange={(e) => setBanDuration(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPending(null)}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmBan}
|
||||
disabled={busy || !banReason}
|
||||
>
|
||||
Ban
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={pending?.kind === "give-currency"} onOpenChange={(o) => !o && setPending(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Give currency to {pending?.kind === "give-currency" ? pending.ids.length : 0} users
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Type</Label>
|
||||
<Select value={currencyType} onValueChange={(v) => setCurrencyType(v as typeof currencyType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="credits">Credits</SelectItem>
|
||||
<SelectItem value="pixels">Pixels (Duckets)</SelectItem>
|
||||
<SelectItem value="points">Points (Diamonds)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="give-amount">Amount</Label>
|
||||
<Input
|
||||
id="give-amount"
|
||||
type="number"
|
||||
value={currencyAmount}
|
||||
onChange={(e) => setCurrencyAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPending(null)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={confirmGiveCurrency} disabled={busy || !currencyAmount}>
|
||||
Give
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={pending?.kind === "give-currency"}
|
||||
onOpenChange={(o) => !o && setPending(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Give currency to{" "}
|
||||
{pending?.kind === "give-currency" ? pending.ids.length : 0} users
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
value={currencyType}
|
||||
onValueChange={(v) => setCurrencyType(v as typeof currencyType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="credits">Credits</SelectItem>
|
||||
<SelectItem value="pixels">Pixels (Duckets)</SelectItem>
|
||||
<SelectItem value="points">Points (Diamonds)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="give-amount">Amount</Label>
|
||||
<Input
|
||||
id="give-amount"
|
||||
type="number"
|
||||
value={currencyAmount}
|
||||
onChange={(e) => setCurrencyAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPending(null)}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmGiveCurrency}
|
||||
disabled={busy || !currencyAmount}
|
||||
>
|
||||
Give
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={pending?.kind === "give-badge"} onOpenChange={(o) => !o && setPending(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Give badge to {pending?.kind === "give-badge" ? pending.ids.length : 0} users
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="badge-code">Badge code</Label>
|
||||
<Input
|
||||
id="badge-code"
|
||||
value={badgeCode}
|
||||
onChange={(e) => setBadgeCode(e.target.value)}
|
||||
placeholder="ACH_*, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPending(null)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={confirmGiveBadge} disabled={busy || !badgeCode.trim()}>
|
||||
Give
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
<Dialog
|
||||
open={pending?.kind === "give-badge"}
|
||||
onOpenChange={(o) => !o && setPending(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Give badge to{" "}
|
||||
{pending?.kind === "give-badge" ? pending.ids.length : 0} users
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="badge-code">Badge code</Label>
|
||||
<Input
|
||||
id="badge-code"
|
||||
value={badgeCode}
|
||||
onChange={(e) => setBadgeCode(e.target.value)}
|
||||
placeholder="ACH_*, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPending(null)}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmGiveBadge}
|
||||
disabled={busy || !badgeCode.trim()}
|
||||
>
|
||||
Give
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user