revert fb8e77bb68
Local Build and Deploy / deploy (push) Successful in 1m11s

revert style: clean up code with prettier and eslint
This commit is contained in:
remco committed 2026-07-12 21:02:03 +02:00
1 parent 27de078f54
commit e85e4d74ea
378 files changed
+20710 -22428

No files matched your search

+2 -2
View File
@@ -1,4 +1,4 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Skeleton } from '@/components/ui/skeleton'
export default function UserDetailLoading() {
return (
@@ -46,5 +46,5 @@ export default function UserDetailLoading() {
</div>
</div>
</div>
);
)
}
+4 -4
View File
@@ -1,11 +1,11 @@
import { redirect } from "next/navigation";
import { redirect } from 'next/navigation'
/** Legacy route — redirects to /admin/users/show/[id] */
export default async function UserLegacyPage({
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}`)
}
+31 -27
View File
@@ -1,37 +1,37 @@
import { MessageSquare, Terminal } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { MessageSquare, Terminal } from 'lucide-react'
import Link from 'next/link'
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);
if (!ts) return '-'
const d = new Date(ts * 1000)
return d.toLocaleString(undefined, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})
}
export function UserActivityLog({ chatLogs, commandLogs }: UserActivityLogProps) {
@@ -59,7 +59,9 @@ export function UserActivityLog({ chatLogs, commandLogs }: UserActivityLogProps)
>
Room #{log.roomId}
</Link>
<span className="text-xs text-muted-foreground">{formatTime(log.timestamp)}</span>
<span className="text-xs text-muted-foreground">
{formatTime(log.timestamp)}
</span>
</div>
<p className="break-words">{log.message}</p>
</div>
@@ -88,12 +90,14 @@ export function UserActivityLog({ chatLogs, commandLogs }: UserActivityLogProps)
<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"}
variant={log.succes === 'yes' ? 'default' : 'destructive'}
className="text-[10px]"
>
{log.succes === "yes" ? "OK" : "FAIL"}
{log.succes === 'yes' ? 'OK' : 'FAIL'}
</Badge>
<span className="text-xs text-muted-foreground">{formatTime(log.timestamp)}</span>
<span className="text-xs text-muted-foreground">
{formatTime(log.timestamp)}
</span>
</div>
</div>
{log.params && (
@@ -106,5 +110,5 @@ export function UserActivityLog({ chatLogs, commandLogs }: UserActivityLogProps)
</CardContent>
</Card>
</div>
);
)
}
@@ -1,39 +1,39 @@
"use client";
'use client'
import { Plus, X } from "lucide-react";
import { useState } from "react";
import { giveBadge, removeBadge } from "@/actions/users";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { useServerAction } from "@/hooks/use-server-action";
import { Plus, X } from 'lucide-react'
import { useState } from 'react'
import { giveBadge, removeBadge } from '@/actions/users'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
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;
if (!newBadge.trim()) return
run(() => giveBadge({ userId, badgeCode: newBadge.trim() }), {
successMessage: `Badge "${newBadge.trim()}" added.`,
onSuccess: () => setNewBadge(""),
});
onSuccess: () => setNewBadge(''),
})
}
function handleRemoveBadge(badgeCode: string) {
run(() => removeBadge({ userId, badgeCode }), {
successMessage: `Badge "${badgeCode}" removed.`,
});
})
}
return (
@@ -47,7 +47,7 @@ export function UserBadgesSection({ userId, badges }: UserBadgesSectionProps) {
value={newBadge}
onChange={(e) => setNewBadge(e.target.value)}
placeholder="Badge code..."
onKeyDown={(e) => e.key === "Enter" && handleAddBadge()}
onKeyDown={(e) => e.key === 'Enter' && handleAddBadge()}
/>
<Button size="icon" onClick={handleAddBadge} disabled={isPending}>
<Plus className="h-4 w-4" />
@@ -69,7 +69,7 @@ export function UserBadgesSection({ userId, badges }: UserBadgesSectionProps) {
alt={badge.badgeCode}
className="h-5 w-5"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
;(e.target as HTMLImageElement).style.display = 'none'
}}
/>
<span>{badge.badgeCode}</span>
@@ -88,5 +88,5 @@ export function UserBadgesSection({ userId, badges }: UserBadgesSectionProps) {
</div>
</CardContent>
</Card>
);
)
}
+44 -38
View File
@@ -1,43 +1,49 @@
"use client";
'use client'
import { Ban, ShieldOff } from "lucide-react";
import { useState } from "react";
import { banUser, unbanUser } 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
import { Ban, ShieldOff } from 'lucide-react'
import { useState } from 'react'
import { banUser, unbanUser } 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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
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");
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 now = Math.floor(Date.now() / 1000)
return b.banExpire === 0 || b.banExpire > now
})
function handleBan() {
run(
@@ -46,22 +52,22 @@ export function UserBanSection({ userId, username, bans }: UserBanSectionProps)
userId,
reason,
duration: parseInt(duration, 10),
type: banType as "account" | "ip" | "machine",
type: banType as 'account' | 'ip' | 'machine',
}),
{
successMessage: `${username} has been banned.`,
onSuccess: () => {
setShowBanForm(false);
setReason("");
setShowBanForm(false)
setReason('')
},
},
);
)
}
function handleUnban() {
run(() => unbanUser({ userId }), {
successMessage: `${username} has been unbanned.`,
});
})
}
return (
@@ -88,9 +94,9 @@ export function UserBanSection({ userId, username, bans }: UserBanSectionProps)
<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} &middot;{" "}
By {activeBan.staffName} &middot;{' '}
{activeBan.banExpire === 0
? "Permanent"
? 'Permanent'
: `Expires ${new Date(activeBan.banExpire * 1000).toLocaleString()}`}
</p>
</div>
@@ -131,7 +137,7 @@ export function UserBanSection({ userId, username, bans }: UserBanSectionProps)
</div>
</div>
<Button variant="destructive" onClick={handleBan} disabled={isPending || !reason}>
{isPending ? "Banning..." : "Confirm Ban"}
{isPending ? 'Banning...' : 'Confirm Ban'}
</Button>
</div>
)}
@@ -156,5 +162,5 @@ export function UserBanSection({ userId, username, bans }: UserBanSectionProps)
)}
</CardContent>
</Card>
);
)
}
+58 -46
View File
@@ -1,46 +1,52 @@
"use client";
'use client'
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { updateUser } from "@/actions/users";
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 { useServerAction } from "@/hooks/use-server-action";
import { type UpdateUserInput, updateUserSchema } from "@/lib/validators/user";
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { updateUser } from '@/actions/users'
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 { 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;
};
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 }>;
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,
@@ -59,12 +65,12 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
diamonds: user.diamonds,
duckets: user.duckets,
},
});
})
function onSubmit(data: UpdateUserInput) {
run(() => updateUser({ id: user.id, ...data }), {
successMessage: "User updated successfully.",
});
successMessage: 'User updated successfully.',
})
}
return (
@@ -77,12 +83,14 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
<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>}
<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")} />
<Input id="mail" type="email" {...register('mail')} />
{errors.mail && <p className="text-sm text-destructive">{errors.mail.message}</p>}
</div>
</div>
@@ -92,7 +100,7 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
<Label htmlFor="rank">Rank</Label>
<Select
defaultValue={String(user.rank)}
onValueChange={(v) => setValue("rank", parseInt(v, 10))}
onValueChange={(v) => setValue('rank', parseInt(v, 10))}
>
<SelectTrigger>
<SelectValue placeholder="Select rank" />
@@ -108,34 +116,38 @@ export function UserEditForm({ user, ranks }: UserEditFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor="motto">Motto</Label>
<Input id="motto" {...register("motto")} />
<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 })} />
<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 })} />
<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 })} />
<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 })} />
<Input id="duckets" type="number" {...register('duckets', { valueAsNumber: true })} />
</div>
</div>
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save Changes"}
{isPending ? 'Saving...' : 'Save Changes'}
</Button>
</form>
</CardContent>
</Card>
);
)
}
+62 -49
View File
@@ -1,89 +1,102 @@
"use client";
'use client'
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 { 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useServerAction } from "@/hooks/use-server-action";
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 { 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 { 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();
const { run, isPending } = useServerAction()
// Reset password state
const [newPassword, setNewPassword] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [newPassword, setNewPassword] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
// Alert state
const [alertMessage, setAlertMessage] = useState("");
const [alertMessage, setAlertMessage] = useState('')
// Mute state
const [muteDuration, setMuteDuration] = useState("10");
const [muteDuration, setMuteDuration] = useState('10')
// Credits state
const [creditAmount, setCreditAmount] = useState("1000");
const [creditAmount, setCreditAmount] = useState('1000')
function handleResetPassword() {
if (!confirm(`Reset password for ${username}? A new random password will be generated.`)) return;
if (!confirm(`Reset password for ${username}? A new random password will be generated.`)) return
run(() => resetPassword({ userId }), {
successMessage: "Password reset successfully.",
successMessage: 'Password reset successfully.',
onSuccess: (data) => {
setNewPassword(data.newPassword as string);
setNewPassword(data.newPassword as string)
},
});
})
}
function handleDisconnect() {
run(() => disconnectUser({ userId }), {
successMessage: `${username} disconnected.`,
});
})
}
function handleAlert() {
if (!alertMessage.trim()) return;
if (!alertMessage.trim()) return
run(() => alertUser({ userId, message: alertMessage.trim() }), {
successMessage: "Alert sent.",
onSuccess: () => setAlertMessage(""),
});
successMessage: 'Alert sent.',
onSuccess: () => setAlertMessage(''),
})
}
function handleMute() {
const duration = Number(muteDuration);
if (Number.isNaN(duration) || duration < 0) return;
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 handleSendCredits() {
const amount = parseInt(creditAmount, 10);
if (!amount || amount <= 0) return;
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);
navigator.clipboard.writeText(newPassword)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
@@ -129,10 +142,14 @@ export function UserQuickActions({ userId, username, isOnline }: UserQuickAction
placeholder="Message to send..."
value={alertMessage}
onChange={(e) => setAlertMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAlert()}
onKeyDown={(e) => e.key === 'Enter' && handleAlert()}
className="flex-1"
/>
<Button onClick={handleAlert} disabled={isPending || !alertMessage.trim()} size="icon">
<Button
onClick={handleAlert}
disabled={isPending || !alertMessage.trim()}
size="icon"
>
<MessageCircle className="h-4 w-4" />
</Button>
</div>
@@ -183,8 +200,8 @@ export function UserQuickActions({ userId, username, isOnline }: UserQuickAction
<DialogHeader>
<DialogTitle>Password Reset</DialogTitle>
<DialogDescription>
New password generated for <strong>{username}</strong>. Copy it now — it won&apos;t be shown
again.
New password generated for <strong>{username}</strong>. Copy it now — it won&apos;t be
shown again.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 rounded-lg border bg-muted p-3">
@@ -192,15 +209,11 @@ export function UserQuickActions({ userId, username, isOnline }: UserQuickAction
{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" />
)}
{copied ? <Check className="h-4 w-4 text-[var(--admin-success)]" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
)
}
+18 -18
View File
@@ -1,26 +1,26 @@
import { DoorOpen, Users } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DoorOpen, Users } from 'lucide-react'
import Link from 'next/link'
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 (
@@ -45,7 +45,7 @@ export function UserRooms({ rooms }: UserRoomsProps) {
<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]">
<Badge variant={stateColors[room.state] || 'outline'} className="text-[10px]">
{room.state}
</Badge>
<span className="text-xs text-muted-foreground flex items-center gap-1">
@@ -61,5 +61,5 @@ export function UserRooms({ rooms }: UserRoomsProps) {
)}
</CardContent>
</Card>
);
)
}
@@ -1,10 +1,10 @@
"use client";
'use client'
import { Loader2, UserPlus } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { createUser } from "@/actions/users";
import { Button } from "@/components/ui/button";
import { Loader2, UserPlus } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { createUser } from '@/actions/users'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
@@ -12,69 +12,75 @@ import {
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 { useServerAction } from "@/hooks/use-server-action";
} 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 { 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("");
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 });
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();
const u = username.trim()
const m = mail.trim()
if (!u || !m || !password) {
toast.error("Username, email and password are required");
return;
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;
toast.error('Invalid username (3-20 chars, letters/digits/_/-)')
return
}
if (password.length < 6) {
toast.error("Password must be at least 6 characters");
return;
toast.error('Password must be at least 6 characters')
return
}
run(
@@ -89,19 +95,19 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
{
successMessage: `User "${u}" created.`,
onSuccess: () => {
setOpen(false);
reset();
setOpen(false)
reset()
},
},
);
)
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
setOpen(next)
if (!next) reset()
}}
>
<DialogTrigger asChild>
@@ -194,5 +200,5 @@ export function CreateUserDialog({ sessionRank }: CreateUserDialogProps) {
</div>
</DialogContent>
</Dialog>
);
)
}
@@ -1,19 +1,19 @@
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";
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;
};
accountCreated: number
lastLogin: number | null
ipRegister: string
ipCurrent: string
twoFactorConfirmed: number
machine_id: string
mail: string
gender: string
}
}
export function UserAccountInfo({ user }: UserAccountInfoProps) {
@@ -29,10 +29,10 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
<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",
{new Date(user.accountCreated * 1000).toLocaleDateString('it-IT', {
day: '2-digit',
month: 'short',
year: 'numeric',
})}
</span>
</div>
@@ -41,22 +41,27 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
<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",
? new Date(user.lastLogin * 1000).toLocaleDateString('it-IT', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: "Never"}
: '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">
<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>
@@ -65,8 +70,13 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
<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">
<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>
@@ -76,15 +86,15 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
<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 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"}
{user.machine_id || 'N/A'}
</code>
</div>
<div className="flex items-center gap-3 text-sm">
@@ -101,5 +111,5 @@ export function UserAccountInfo({ user }: UserAccountInfoProps) {
</div>
</CardContent>
</Card>
);
)
}
@@ -1,19 +1,22 @@
"use client";
'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>;
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">
<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
@@ -23,12 +26,12 @@ export function UserBadgesReadonly({ badges }: { badges: Badge[] }) {
alt={badge.badgeCode}
className="h-5 w-5"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
;(e.target as HTMLImageElement).style.display = 'none'
}}
/>
<span>{badge.badgeCode}</span>
</div>
))}
</div>
);
)
}
+50 -45
View File
@@ -11,38 +11,38 @@ import {
Sparkles,
Wifi,
WifiOff,
} from "lucide-react";
import Link from "next/link";
import { AdminBreadcrumb } from "@/components/admin/admin-breadcrumb";
import { UserWatchToggle } from "@/components/admin/user-watch-toggle";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { getAvatarUrl } from "@/lib/imager";
} from 'lucide-react'
import Link from 'next/link'
import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb'
import { UserWatchToggle } from '@/components/admin/user-watch-toggle'
import { Badge } from '@/components/ui/badge'
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;
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;
watching?: boolean | null
}
export function UserHero({
@@ -62,9 +62,9 @@ export function UserHero({
<>
<AdminBreadcrumb
items={[
{ label: "Users", href: "/admin/users" },
{ label: 'Users', href: '/admin/users' },
{ label: user.username },
{ label: mode === "edit" ? "Edit" : "View" },
{ label: mode === 'edit' ? 'Edit' : 'View' },
]}
/>
@@ -76,14 +76,16 @@ export function UserHero({
src={getAvatarUrl(user.look, { headDirection: 2, direction: 2 })}
alt={user.username}
className="h-28 rounded-lg border bg-card shadow-sm"
style={{ imageRendering: "pixelated" }}
style={{ imageRendering: 'pixelated' }}
/>
<span className={`absolute -top-1 -right-1 flex h-4 w-4 ${isOnline ? "" : "opacity-50"}`}>
<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="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)]"}`}
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>
@@ -96,14 +98,14 @@ export function UserHero({
ID: {user.id}
</Badge>
{/* Mode switch buttons */}
{mode === "show" && canEdit && (
{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" && (
{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
@@ -111,17 +113,23 @@ export function UserHero({
</Button>
)}
{watching != null && (
<UserWatchToggle targetUserId={user.id} username={user.username} initialWatching={watching} />
<UserWatchToggle
targetUserId={user.id}
username={user.username}
initialWatching={watching}
/>
)}
</div>
{user.motto && <p className="text-muted-foreground italic">&ldquo;{user.motto}&rdquo;</p>}
{user.motto && (
<p className="text-muted-foreground italic">&ldquo;{user.motto}&rdquo;</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)]" : ""}
variant={isOnline ? 'default' : 'outline'}
className={isOnline ? 'bg-[var(--admin-success-subtle)]' : ''}
>
{isOnline ? (
<>
@@ -135,10 +143,7 @@ export function UserHero({
</Badge>
{activeBan && <Badge variant="destructive">Banned</Badge>}
{user.twoFactorConfirmed ? (
<Badge
variant="outline"
className="border-[var(--admin-success-border)] text-[var(--admin-success)]"
>
<Badge variant="outline" className="border-[var(--admin-success-border)] text-[var(--admin-success)]">
<Shield className="mr-1 h-3 w-3" /> 2FA
</Badge>
) : null}
@@ -193,5 +198,5 @@ export function UserHero({
</div>
</div>
</>
);
)
}
+17 -37
View File
@@ -7,52 +7,32 @@ export async function loadUserById(id: string) {
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;
+36 -26
View File
@@ -1,37 +1,41 @@
import { redirect } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Prisma } from "@/generated/prisma/client";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import { getWatchedUserIds } from "@/lib/services/watch";
import { UserAccountInfo } from "../../_components/user-account-info";
import { UserHero } from "../../_components/user-hero";
import { loadUserById } from "../../_lib/load-user";
import { UserActivityLog } from "../../[id]/user-activity-log";
import { UserBadgesSection } from "../../[id]/user-badges-section";
import { UserBanSection } from "../../[id]/user-ban-section";
import { UserEditForm } from "../../[id]/user-edit-form";
import { UserQuickActions } from "../../[id]/user-quick-actions";
import { UserRooms } from "../../[id]/user-rooms";
import { redirect } from 'next/navigation'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Prisma } from '@/generated/prisma/client'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
import { getWatchedUserIds } from '@/lib/services/watch'
import { UserAccountInfo } from '../../_components/user-account-info'
import { UserHero } from '../../_components/user-hero'
import { loadUserById } from '../../_lib/load-user'
import { UserActivityLog } from '../../[id]/user-activity-log'
import { UserBadgesSection } from '../../[id]/user-badges-section'
import { UserBanSection } from '../../[id]/user-ban-section'
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();
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");
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}`),
}));
}))
return (
<div className="space-y-6">
@@ -55,7 +59,9 @@ export default async function UserEditPage({ params }: { params: Promise<{ local
<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" />}
{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>
@@ -119,9 +125,13 @@ export default async function UserEditPage({ params }: { params: Promise<{ local
</Tabs>
<aside className="lg:sticky lg:top-4 lg:self-start">
<UserQuickActions userId={data.user.id} username={data.user.username} isOnline={data.isOnline} />
<UserQuickActions
userId={data.user.id}
username={data.user.username}
isOnline={data.isOnline}
/>
</aside>
</div>
</div>
);
)
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Skeleton } from "@/components/ui/skeleton";
import { Skeleton } from '@/components/ui/skeleton'
export default function UsersLoading() {
return (
@@ -42,5 +42,5 @@ export default function UsersLoading() {
))}
</div>
</div>
);
)
}
+22 -19
View File
@@ -1,34 +1,34 @@
import Link from "next/link";
import { redirect } from "next/navigation";
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";
import Link from 'next/link'
import { redirect } from 'next/navigation'
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: 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 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.
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>
@@ -55,7 +55,10 @@ export default async function MultiAccountsPage({
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">
<button
type="submit"
className="h-9 rounded-md bg-primary px-4 text-sm text-primary-foreground"
>
Refresh
</button>
</form>
@@ -89,7 +92,7 @@ export default async function MultiAccountsPage({
<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" && (
{acc.online === '1' && (
<span className="h-1.5 w-1.5 rounded-full bg-[var(--admin-success-subtle)]" />
)}
</Link>
@@ -106,5 +109,5 @@ export default async function MultiAccountsPage({
</div>
)}
</div>
);
)
}
+15 -15
View File
@@ -1,20 +1,20 @@
import { fetchAdminList } from "@/lib/admin-list";
import { canAccess, PERMS } from "@/lib/permissions";
import { CreateUserDialog } from "./_components/create-user-dialog";
import { UsersTable } from "./users-table";
import { fetchAdminList } from '@/lib/admin-list'
import { canAccess, PERMS } from '@/lib/permissions'
import { CreateUserDialog } from './_components/create-user-dialog'
import { UsersTable } from './users-table'
export default async function UsersPage({
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"],
model: 'user',
searchFields: ['username', 'mail', 'ipRegister', 'ipCurrent'],
select: {
id: true,
username: true,
@@ -31,20 +31,20 @@ export default async function UsersPage({
},
filterMap: {
filter_rank: (v) => {
const n = Number(v);
if (!Number.isFinite(n)) return null;
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 };
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">
@@ -68,5 +68,5 @@ export default async function UsersPage({
canBan={canBan}
/>
</div>
);
)
}
+33 -21
View File
@@ -1,25 +1,29 @@
import { redirect } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { getWatchedUserIds } from "@/lib/services/watch";
import { UserAccountInfo } from "../../_components/user-account-info";
import { UserBadgesReadonly } from "../../_components/user-badges-readonly";
import { UserHero } from "../../_components/user-hero";
import { loadUserById } from "../../_lib/load-user";
import { UserActivityLog } from "../../[id]/user-activity-log";
import { UserQuickActions } from "../../[id]/user-quick-actions";
import { UserRooms } from "../../[id]/user-rooms";
import { redirect } from 'next/navigation'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { getWatchedUserIds } from '@/lib/services/watch'
import { UserAccountInfo } from '../../_components/user-account-info'
import { UserBadgesReadonly } from '../../_components/user-badges-readonly'
import { UserHero } from '../../_components/user-hero'
import { loadUserById } from '../../_lib/load-user'
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();
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");
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">
@@ -37,7 +41,11 @@ export default async function UserShowPage({ params }: { params: Promise<{ local
watching={watched.has(data.user.id)}
/>
<div className={canEdit ? "grid gap-6 lg:grid-cols-[1fr_320px] xl:grid-cols-[1fr_360px]" : undefined}>
<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>
@@ -68,10 +76,14 @@ export default async function UserShowPage({ params }: { params: Promise<{ local
{canEdit && (
<aside className="lg:sticky lg:top-4 lg:self-start">
<UserQuickActions userId={data.user.id} username={data.user.username} isOnline={data.isOnline} />
<UserQuickActions
userId={data.user.id}
username={data.user.username}
isOnline={data.isOnline}
/>
</aside>
)}
</div>
</div>
);
)
}
+177 -159
View File
@@ -1,13 +1,13 @@
"use client";
'use client'
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 { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
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 { DataTable } from '@/components/admin/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
@@ -15,102 +15,113 @@ import {
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 { getAvatarUrl } from "@/lib/imager";
import type { DataTableColumn, PaginatedResult } from "@/types";
} 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 { 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: 'id', label: 'ID', sortable: true },
{
key: "username",
label: "User",
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">
<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 })}
src={getAvatarUrl(row.look, { size: 's', headOnly: true, headDirection: 2 })}
alt=""
className="h-8 w-8 rounded"
style={{ imageRendering: "pixelated" }}
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>
<span className="text-xs text-muted-foreground truncate block max-w-[200px]">
{row.motto}
</span>
)}
</div>
</Link>
),
},
{ key: "mail", label: "Email", sortable: true },
{ key: 'mail', label: 'Email', sortable: true },
{
key: "rank",
label: "Rank",
key: 'rank',
label: 'Rank',
sortable: true,
filterKey: "filter_rank",
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" },
{ 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);
const r = Number(value)
return (
<Badge variant={r >= 7 ? "default" : r >= 5 ? "secondary" : "outline"}>
<Badge variant={r >= 7 ? 'default' : r >= 5 ? 'secondary' : 'outline'}>
{RANK_LABELS[r] || `Rank ${r}`}
</Badge>
);
)
},
},
{
key: "credits",
label: "Credits",
key: 'credits',
label: 'Credits',
sortable: true,
render: (value) => <span className="tabular-nums">{Number(value).toLocaleString()}</span>,
},
{
key: "online",
label: "Status",
filterKey: "filter_online",
key: 'online',
label: 'Status',
filterKey: 'filter_online',
filterOptions: [
{ label: "Online", value: "1" },
{ label: "Offline", value: "0" },
{ label: 'Online', value: '1' },
{ label: 'Offline', value: '0' },
],
render: (value) =>
value === "1" ? (
value === '1' ? (
<span className="flex items-center gap-1.5 text-[var(--admin-success)]">
<Wifi className="h-3.5 w-3.5" /> Online
</span>
@@ -121,26 +132,26 @@ function getColumns(canEdit: boolean): DataTableColumn<UserRow>[] {
),
},
{
key: "accountCreated",
label: "Registered",
key: 'accountCreated',
label: 'Registered',
sortable: true,
render: (value) => {
const ts = Number(value);
if (!ts) return "-";
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",
{new Date(ts * 1000).toLocaleDateString('it-IT', {
day: '2-digit',
month: 'short',
year: 'numeric',
})}
</span>
);
)
},
},
{
key: "actions",
label: "",
key: 'actions',
label: '',
render: (_, row) => (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" asChild title="View">
@@ -158,147 +169,147 @@ function getColumns(canEdit: boolean): DataTableColumn<UserRow>[] {
</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`);
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));
const numericIds = ids.map((i) => Number(i))
if (action === "unban") {
setBusy(true);
if (action === 'unban') {
setBusy(true)
try {
const result = await bulkUnban({ userIds: numericIds });
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);
const data = result.data as { unbanned: number; total: number }
reportResult('Unbanned', data.unbanned, data.total)
} else toast.error(result.error)
} finally {
setBusy(false);
setBusy(false)
}
return;
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);
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);
const data = result.data as { banned: number }
reportResult('Banned', data.banned, pending.ids.length)
} else toast.error(result.error)
setPending(null)
} finally {
setBusy(false);
setBusy(false)
}
}
async function confirmGiveCurrency() {
if (pending?.kind !== "give-currency") return;
setBusy(true);
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 }[];
};
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);
)
} else toast.error(result.error)
setPending(null)
} finally {
setBusy(false);
setBusy(false)
}
}
async function confirmGiveBadge() {
if (pending?.kind !== "give-badge") return;
setBusy(true);
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);
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);
setBusy(false)
}
}
const bulkActions: { label: string; value: string; variant?: "destructive" | "default" }[] = [];
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" });
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" });
bulkActions.push({ label: 'Give credits', value: 'give-currency' })
bulkActions.push({ label: 'Give badge', value: 'give-badge' })
}
return (
@@ -308,25 +319,29 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
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" } },
{ 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}
exportUrl={canEdit ? '/api/admin/export?type=users' : undefined}
/>
<Dialog open={pending?.kind === "ban"} onOpenChange={(o) => !o && setPending(null)}>
<Dialog open={pending?.kind === 'ban'} onOpenChange={(o) => !o && setPending(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ban {pending?.kind === "ban" ? pending.ids.length : 0} users</DialogTitle>
<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)} />
<Input
id="ban-reason"
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
/>
</div>
<div>
<Label htmlFor="ban-duration">Duration (seconds, -1 = permanent)</Label>
@@ -349,17 +364,20 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
</DialogContent>
</Dialog>
<Dialog open={pending?.kind === "give-currency"} onOpenChange={(o) => !o && setPending(null)}>
<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
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)}>
<Select
value={currencyType}
onValueChange={(v) => setCurrencyType(v as typeof currencyType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -391,11 +409,11 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
</DialogContent>
</Dialog>
<Dialog open={pending?.kind === "give-badge"} onOpenChange={(o) => !o && setPending(null)}>
<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
Give badge to {pending?.kind === 'give-badge' ? pending.ids.length : 0} users
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
@@ -420,5 +438,5 @@ export function UsersTable({ data, canEdit, canBan }: UsersTableProps) {
</DialogContent>
</Dialog>
</>
);
)
}