Reapply "Add missing admin action files and navigation links"

This reverts commit 4d515bc400.
This commit is contained in:
Simo committed 2026-07-11 20:52:56 +02:00
1 parent 96ed768f14
commit 5b4228261a
338 files changed
+28143 -5948

No files matched your search

@@ -0,0 +1,204 @@
'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 {
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 { useServerAction } from '@/hooks/use-server-action'
interface CreateUserDialogProps {
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)' },
]
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 allowedRanks = RANK_OPTIONS.filter((r) => (sessionRank >= 7 ? true : r.value < sessionRank))
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 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()
},
},
)
}
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>
)
}
@@ -0,0 +1,115 @@
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
}
}
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>
)
}
@@ -0,0 +1,37 @@
'use client'
interface Badge {
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 (
<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>
)
}
@@ -0,0 +1,202 @@
import {
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'
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
/** 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,
}: UserHeroProps) {
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-green-400 opacity-75" />
)}
<span
className={`relative inline-flex rounded-full h-4 w-4 border-2 border-card ${isOnline ? 'bg-green-500' : 'bg-zinc-400'}`}
/>
</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>
{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-green-600' : ''}
>
{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-green-500/50 text-green-600">
<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-yellow-500" />
<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-blue-400" />
<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-purple-400" />
<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-pink-400" />
<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>
</>
)
}