114 lines
4.3 KiB
TypeScript
114 lines
4.3 KiB
TypeScript
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>>
|
|
}) {
|
|
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 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>
|
|
|
|
<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(--color-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>
|
|
)
|
|
}
|