Loading packages/admin/src/components/sidebar/Sidebar.tsx +11 −1 Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ import { Clock, House, List, Radar, Server, Settings, Square, Loading @@ -24,7 +25,10 @@ export const SidebarWrapper = () => { return ( <aside className="h-screen z-[202] sticky top-0"> {collapsed ? ( <div className={Sidebar.Overlay()} onClick={() => setCollapsed(false)} /> <div className={Sidebar.Overlay()} onClick={() => setCollapsed(false)} /> ) : null} <div className={Sidebar({ Loading Loading @@ -83,6 +87,12 @@ export const SidebarWrapper = () => { icon={<Server aria-hidden="true" size={16} />} href="/instances" /> <SidebarItem isActive={pathname === "/alts"} title="IP Alt Overview" icon={<Radar aria-hidden="true" size={16} />} href="/alts" /> <SidebarItem isActive={pathname === "/accounts/settings"} title="Settings" Loading packages/admin/src/main.tsx +6 −1 Original line number Diff line number Diff line Loading @@ -6,6 +6,7 @@ import { SWRConfig } from "swr"; import { ToastWrapper } from "./components/ToastWrapper.tsx"; import { AccountDetailPage } from "./pages/Accounts/AccountDetail/page.tsx"; import { AccountsPage } from "./pages/Accounts/Accounts/page.tsx"; import { AltOverviewPage } from "./pages/AltOverview/page.tsx"; import { AuditLog } from "./pages/AuditLog/auditlog.tsx"; import { HomePage } from "./pages/Home/page.tsx"; import { InstanceUsersPage } from "./pages/Instances/InstanceUsers.tsx"; Loading @@ -25,6 +26,10 @@ const router = createBrowserRouter( path: "/", element: <HomePage />, }, { path: "/alts", element: <AltOverviewPage />, }, { path: "/accounts", element: <AccountsPage />, Loading packages/admin/src/pages/Accounts/AccountDetail/page.tsx +94 −1 Original line number Diff line number Diff line Loading @@ -8,7 +8,7 @@ import { Switch, TextArea, } from "@heroui/react"; import { ArrowLeft, Ban, MessageCircle, ShieldQuestion } from "lucide-react"; import { ArrowLeft, Ban, MessageCircle, Radar, ShieldQuestion, Users } from "lucide-react"; import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "react-toastify"; Loading Loading @@ -41,6 +41,22 @@ interface BanInfo { createdAt: string; } interface AltAccount { sub: string; username: string; display_name: string | null; picture_url: string | null; profile_url: string | null; isAdmin: boolean; isModerator: boolean; pixelStack: number; lastTimeGainStarted: string; sharedIps: string[]; sharedCount: number; nearbyPixelCount: number; nameMatch: "exact" | "fuzzy" | "none"; } export const AccountDetailPage = () => { const { sub } = useParams<{ sub: string }>(); const navigate = useNavigate(); Loading @@ -49,6 +65,7 @@ export const AccountDetailPage = () => { const [loading, setLoading] = useState(true); const [userIPs, setUserIPs] = useState<IPAddress[]>([]); const [userBan, setUserBan] = useState<BanInfo | null>(null); const [alts, setAlts] = useState<AltAccount[]>([]); const [roleSaving, setRoleSaving] = useState(false); const [isAdmin, setAdmin] = useState(false); Loading Loading @@ -99,6 +116,13 @@ export const AccountDetailPage = () => { setBanPrivateNote(banResult.data.ban.privateNote ?? ""); } const altsResult = await api<{ alts: AltAccount[] }>( `/api/mod/user/${encodeURIComponent(sub)}/alts`, ); if (altsResult.status === 200 && altsResult.data.success) { setAlts(altsResult.data.alts); } setLoading(false); }; Loading Loading @@ -355,6 +379,75 @@ export const AccountDetailPage = () => { )} </div> {/* Alternate Accounts */} <div> <h4 className="text-lg font-semibold mb-2 flex items-center gap-2"> <Users size={18} /> Alternate Accounts </h4> {alts.length === 0 ? ( <span className="text-xs text-default-500"> No alternate accounts detected </span> ) : ( <div className="flex flex-col gap-3"> {alts.map((alt) => ( <div key={alt.sub} className="flex items-center gap-3 rounded-lg bg-default-100 p-3 cursor-pointer hover:bg-default-200 transition-colors" onClick={() => navigate( `/accounts/${encodeURIComponent(alt.sub)}`, ) } > <Avatar size="sm"> <Avatar.Image src={alt.picture_url ?? undefined} /> <Avatar.Fallback> {alt.username?.charAt(0)?.toUpperCase()} </Avatar.Fallback> </Avatar> <div className="flex flex-col min-w-0 flex-1"> <div className="flex items-center gap-2"> <span className="text-sm font-medium truncate"> {alt.username} </span> {alt.display_name && ( <span className="text-xs text-default-500 truncate"> ({alt.display_name}) </span> )} </div> <div className="flex flex-wrap gap-1 mt-1"> {alt.sharedCount > 0 && ( <span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/20 text-primary"> {alt.sharedCount} shared IP {alt.sharedCount !== 1 ? "s" : ""} </span> )} {alt.nearbyPixelCount > 0 && ( <span className="text-[10px] px-1.5 py-0.5 rounded bg-secondary/20 text-secondary"> <Radar size={10} className="inline mr-0.5" /> {alt.nearbyPixelCount} nearby pixel {alt.nearbyPixelCount !== 1 ? "s" : ""} </span> )} {alt.nameMatch !== "none" && ( <span className="text-[10px] px-1.5 py-0.5 rounded bg-warning/20 text-warning"> {alt.nameMatch === "exact" ? "Identical name" : "Similar name"} </span> )} </div> </div> </div> ))} </div> )} </div> {/* Send Notice */} <div> <h4 className="text-lg font-semibold mb-2 flex items-center gap-2"> Loading packages/admin/src/pages/AltOverview/page.tsx 0 → 100644 +343 −0 Original line number Diff line number Diff line import { Breadcrumbs, BreadcrumbsItem, Button, ProgressCircle, Table, Tabs, } from "@heroui/react"; import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { api, handleError } from "../../lib/utils"; interface SampleUser { sub: string; username: string; display_name: string | null; } interface IPGroup { ip: string; accountCount: number; sampleUsers: SampleUser[]; } interface NameCluster { normalizedName: string; accountCount: number; users: SampleUser[]; } export const AltOverviewPage = () => { const navigate = useNavigate(); const [activeTab, setActiveTab] = useState<string>("ip"); // IP section state const [ips, setIPs] = useState<IPGroup[]>([]); const [ipLoading, setIPLoading] = useState(true); const [ipTotal, setIPTotal] = useState(0); const [ipPage, setIPPage] = useState(0); const [ipPageInput, setIPPageInput] = useState("1"); const ipPerPage = 20; // Name matches section state const [clusters, setClusters] = useState<NameCluster[]>([]); const [nameLoading, setNameLoading] = useState(true); const [nameTotal, setNameTotal] = useState(0); const [namePage, setNamePage] = useState(0); const [namePageInput, setNamePageInput] = useState("1"); const namePerPage = 20; const fetchIPs = async (skip: number) => { setIPLoading(true); const params = new URLSearchParams({ take: String(ipPerPage), skip: String(skip), }); const { status, data } = await api<{ ips: IPGroup[]; meta: { total: number }; }>(`/api/mod/user/alt/overview?${params}`); if (status === 200 && data.success) { setIPs(data.ips); setIPTotal(data.meta.total); } else { handleError(status, data); } setIPLoading(false); }; const fetchNameMatches = async (skip: number) => { setNameLoading(true); const params = new URLSearchParams({ take: String(namePerPage), skip: String(skip), }); const { status, data } = await api<{ clusters: NameCluster[]; meta: { total: number }; }>(`/api/mod/user/alt/name-matches?${params}`); if (status === 200 && data.success) { setClusters(data.clusters); setNameTotal(data.meta.total); } else { handleError(status, data); } setNameLoading(false); }; useEffect(() => { fetchIPs(ipPage * ipPerPage); }, [ipPage]); useEffect(() => { fetchNameMatches(namePage * namePerPage); }, [namePage]); const ipTotalPages = Math.ceil(ipTotal / ipPerPage); const nameTotalPages = Math.ceil(nameTotal / namePerPage); return ( <div className="my-14 lg:px-6 max-w-[95rem] mx-auto w-full flex flex-col gap-4"> <Breadcrumbs> <BreadcrumbsItem href="/">Home</BreadcrumbsItem> <BreadcrumbsItem>Alt Detection</BreadcrumbsItem> </Breadcrumbs> <h3 className="text-xl font-semibold">Alt Detection</h3> <p className="text-sm text-default-500"> Patterns that may indicate alternate accounts: IPs shared by many users, and usernames that differ only by look-alike characters. </p> <Tabs aria-label="Alt detection views" selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(key as string)} > <Tabs.List> <Tabs.Tab id="ip"> IP Overview <span className="text-xs text-default-400 ml-1">({ipTotal})</span> </Tabs.Tab> <Tabs.Tab id="names"> Name Matches <span className="text-xs text-default-400 ml-1">({nameTotal})</span> </Tabs.Tab> </Tabs.List> <Tabs.Panel id="ip"> <div className="flex flex-col gap-4 mt-4"> {ipLoading && <ProgressCircle className="self-center" />} <Table aria-label="IP alt overview"> <Table.ScrollContainer> <Table.Content> <Table.Header> <Table.Column isRowHeader>IP Address</Table.Column> <Table.Column>Accounts</Table.Column> <Table.Column>Sample Users</Table.Column> <Table.Column>Actions</Table.Column> </Table.Header> <Table.Body items={ips}> {(ip) => ( <Table.Row key={ip.ip}> <Table.Cell> <span className="font-mono text-sm">{ip.ip}</span> </Table.Cell> <Table.Cell> <span className="font-semibold"> {ip.accountCount} </span> </Table.Cell> <Table.Cell> <div className="flex flex-wrap gap-1"> {ip.sampleUsers.map((u) => ( <span key={u.sub} className="text-xs text-primary cursor-pointer hover:underline" onClick={() => navigate( `/accounts/${encodeURIComponent(u.sub)}`, ) } > {u.username} </span> ))} </div> </Table.Cell> <Table.Cell> <Button size="sm" variant="tertiary" onPress={() => navigate( `/accounts?search=${encodeURIComponent(ip.ip)}`, ) } > View matching accounts </Button> </Table.Cell> </Table.Row> )} </Table.Body> </Table.Content> </Table.ScrollContainer> </Table> {ipTotalPages > 1 && ( <div className="flex items-center justify-center gap-2"> <Button size="sm" isDisabled={ipPage === 0} onPress={() => setIPPage(ipPage - 1)} > Previous </Button> <span className="text-sm text-default-500"> Page {ipPage + 1} of {ipTotalPages} </span> <Button size="sm" isDisabled={ipPage >= ipTotalPages - 1} onPress={() => setIPPage(ipPage + 1)} > Next </Button> <span className="text-sm text-default-500 ml-2">Jump to</span> <input className="w-16 px-2 py-1 text-sm bg-default-100 rounded border border-default-200 text-center" value={ipPageInput} onChange={(e) => setIPPageInput(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === "Enter") { const p = parseInt(ipPageInput, 10); if (!isNaN(p) && p >= 1 && p <= ipTotalPages) setIPPage(p - 1); } }} /> </div> )} </div> </Tabs.Panel> <Tabs.Panel id="names"> <div className="flex flex-col gap-4 mt-4"> {nameLoading && <ProgressCircle className="self-center" />} {!nameLoading && nameTotal === 0 && ( <span className="text-sm text-default-500"> No name-match clusters found. </span> )} <Table aria-label="Name match clusters"> <Table.ScrollContainer> <Table.Content> <Table.Header> <Table.Column>Normalized Name</Table.Column> <Table.Column>Accounts</Table.Column> <Table.Column>Users</Table.Column> <Table.Column>Actions</Table.Column> </Table.Header> <Table.Body items={clusters}> {(cluster) => ( <Table.Row key={cluster.normalizedName}> <Table.Cell> <span className="font-mono text-sm"> {cluster.normalizedName} </span> </Table.Cell> <Table.Cell> <span className="font-semibold"> {cluster.accountCount} </span> </Table.Cell> <Table.Cell> <div className="flex flex-wrap gap-2"> {cluster.users.map((u) => ( <div key={u.sub} className="flex items-center gap-1.5 cursor-pointer hover:opacity-80" onClick={() => navigate( `/accounts/${encodeURIComponent(u.sub)}`, ) } > <span className="text-xs font-medium"> {u.username} </span> {u.display_name && ( <span className="text-[10px] text-default-500"> ({u.display_name}) </span> )} </div> ))} </div> </Table.Cell> <Table.Cell> <Button size="sm" variant="tertiary" onPress={() => navigate( `/accounts?search=${encodeURIComponent(cluster.normalizedName)}`, ) } > Search accounts </Button> </Table.Cell> </Table.Row> )} </Table.Body> </Table.Content> </Table.ScrollContainer> </Table> {nameTotalPages > 1 && ( <div className="flex items-center justify-center gap-2"> <Button size="sm" isDisabled={namePage === 0} onPress={() => setNamePage(namePage - 1)} > Previous </Button> <span className="text-sm text-default-500"> Page {namePage + 1} of {nameTotalPages} </span> <Button size="sm" isDisabled={namePage >= nameTotalPages - 1} onPress={() => setNamePage(namePage + 1)} > Next </Button> <span className="text-sm text-default-500 ml-2">Jump to</span> <input className="w-16 px-2 py-1 text-sm bg-default-100 rounded border border-default-200 text-center" value={namePageInput} onChange={(e) => setNamePageInput(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === "Enter") { const p = parseInt(namePageInput, 10); if (!isNaN(p) && p >= 1 && p <= nameTotalPages) setNamePage(p - 1); } }} /> </div> )} </div> </Tabs.Panel> </Tabs> </div> ); }; packages/server/src/__test__/lib/username.test.ts 0 → 100644 +128 −0 Original line number Diff line number Diff line import { levenshtein, nameSimilarity, normalizeUsername } from "../../lib/username"; describe("normalizeUsername", () => { it("lowercases and strips spaces", () => { expect(normalizeUsername(" Grant ")).toBe("grant"); }); it("replaces l with i", () => { expect(normalizeUsername("grantl")).toBe("granti"); }); it("replaces I with i", () => { expect(normalizeUsername("grantI")).toBe("granti"); }); it("replaces 1 with i", () => { expect(normalizeUsername("grant1")).toBe("granti"); }); it("replaces | with i", () => { expect(normalizeUsername("grant|")).toBe("granti"); }); it("replaces O -> 0 -> o, and l -> i", () => { // "hellO": lowercase -> "hello", l->i twice -> o unchanged -> "heiio" expect(normalizeUsername("hellO")).toBe("heiio"); }); it("replaces 0 -> o, and l -> i", () => { // "hell0": lowercase -> "hell0", l->i twice, 0->o -> "heiio" expect(normalizeUsername("hell0")).toBe("heiio"); }); it("replaces rn with m", () => { // "po_ta_rn": rn -> m -> "po_ta_m" expect(normalizeUsername("po_ta_rn")).toBe("po_ta_m"); }); it("handles empty string", () => { expect(normalizeUsername("")).toBe(""); }); it("handles strings with no look-alikes", () => { expect(normalizeUsername("xyz")).toBe("xyz"); }); it("combines multiple look-alikes", () => { // "Grantl|0": lowercase -> "grantl|0", l->i, |->i, 0->o -> "grantiio" expect(normalizeUsername("Grantl|0")).toBe("grantiio"); }); }); describe("levenshtein", () => { it("returns 0 for identical strings", () => { expect(levenshtein("hello", "hello")).toBe(0); }); it("returns length of string b when a is empty", () => { expect(levenshtein("", "abc")).toBe(3); }); it("returns length of string a when b is empty", () => { expect(levenshtein("abc", "")).toBe(3); }); it("returns 1 for single substitution", () => { expect(levenshtein("cat", "car")).toBe(1); }); it("returns 1 for single insertion", () => { expect(levenshtein("cat", "cats")).toBe(1); }); it("returns 1 for single deletion", () => { expect(levenshtein("cats", "cat")).toBe(1); }); it("returns correct distance for more complex edit", () => { expect(levenshtein("kitten", "sitting")).toBe(3); }); }); describe("nameSimilarity", () => { it("returns exact when normalized forms match", () => { // "grantl" and "grant1" both normalize to "granti" const result = nameSimilarity("grantl", "grant1"); expect(result.match).toBe("exact"); expect(result.distance).toBe(0); }); it("returns exact when look-alike I vs l", () => { // "admln" and "admin" — but note both normalize, so "admln" -> "admin" (l->i, i->i) // Actually "admin" stays "admin" (a,d,m,i,n — no look-alikes) // "admln" -> "admin" (l->i). So they'd be "exact" match. expect(nameSimilarity("admin", "admln").match).toBe("exact"); }); it("returns fuzzy for single-char difference", () => { const result = nameSimilarity("grant", "grants", 1); expect(result.match).toBe("fuzzy"); expect(result.distance).toBe(1); }); it("returns fuzzy for single substitution", () => { const result = nameSimilarity("grant", "grint", 1); expect(result.match).toBe("fuzzy"); expect(result.distance).toBe(1); }); it("returns none for strings with distance > maxDistance", () => { const result = nameSimilarity("grant", "completely", 1); expect(result.match).toBe("none"); expect(result.distance).toBeGreaterThan(1); }); it("respects custom maxDistance", () => { // "grant" vs "crunt" -> distance 2 (g->c, a->u) const result = nameSimilarity("grant", "crunt", 2); expect(result.match).toBe("fuzzy"); expect(result.distance).toBe(2); }); it("returns exact for identical strings with maxDistance 0", () => { const result = nameSimilarity("grant", "grant", 0); expect(result.match).toBe("exact"); expect(result.distance).toBe(0); }); }); No newline at end of file Loading
packages/admin/src/components/sidebar/Sidebar.tsx +11 −1 Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ import { Clock, House, List, Radar, Server, Settings, Square, Loading @@ -24,7 +25,10 @@ export const SidebarWrapper = () => { return ( <aside className="h-screen z-[202] sticky top-0"> {collapsed ? ( <div className={Sidebar.Overlay()} onClick={() => setCollapsed(false)} /> <div className={Sidebar.Overlay()} onClick={() => setCollapsed(false)} /> ) : null} <div className={Sidebar({ Loading Loading @@ -83,6 +87,12 @@ export const SidebarWrapper = () => { icon={<Server aria-hidden="true" size={16} />} href="/instances" /> <SidebarItem isActive={pathname === "/alts"} title="IP Alt Overview" icon={<Radar aria-hidden="true" size={16} />} href="/alts" /> <SidebarItem isActive={pathname === "/accounts/settings"} title="Settings" Loading
packages/admin/src/main.tsx +6 −1 Original line number Diff line number Diff line Loading @@ -6,6 +6,7 @@ import { SWRConfig } from "swr"; import { ToastWrapper } from "./components/ToastWrapper.tsx"; import { AccountDetailPage } from "./pages/Accounts/AccountDetail/page.tsx"; import { AccountsPage } from "./pages/Accounts/Accounts/page.tsx"; import { AltOverviewPage } from "./pages/AltOverview/page.tsx"; import { AuditLog } from "./pages/AuditLog/auditlog.tsx"; import { HomePage } from "./pages/Home/page.tsx"; import { InstanceUsersPage } from "./pages/Instances/InstanceUsers.tsx"; Loading @@ -25,6 +26,10 @@ const router = createBrowserRouter( path: "/", element: <HomePage />, }, { path: "/alts", element: <AltOverviewPage />, }, { path: "/accounts", element: <AccountsPage />, Loading
packages/admin/src/pages/Accounts/AccountDetail/page.tsx +94 −1 Original line number Diff line number Diff line Loading @@ -8,7 +8,7 @@ import { Switch, TextArea, } from "@heroui/react"; import { ArrowLeft, Ban, MessageCircle, ShieldQuestion } from "lucide-react"; import { ArrowLeft, Ban, MessageCircle, Radar, ShieldQuestion, Users } from "lucide-react"; import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "react-toastify"; Loading Loading @@ -41,6 +41,22 @@ interface BanInfo { createdAt: string; } interface AltAccount { sub: string; username: string; display_name: string | null; picture_url: string | null; profile_url: string | null; isAdmin: boolean; isModerator: boolean; pixelStack: number; lastTimeGainStarted: string; sharedIps: string[]; sharedCount: number; nearbyPixelCount: number; nameMatch: "exact" | "fuzzy" | "none"; } export const AccountDetailPage = () => { const { sub } = useParams<{ sub: string }>(); const navigate = useNavigate(); Loading @@ -49,6 +65,7 @@ export const AccountDetailPage = () => { const [loading, setLoading] = useState(true); const [userIPs, setUserIPs] = useState<IPAddress[]>([]); const [userBan, setUserBan] = useState<BanInfo | null>(null); const [alts, setAlts] = useState<AltAccount[]>([]); const [roleSaving, setRoleSaving] = useState(false); const [isAdmin, setAdmin] = useState(false); Loading Loading @@ -99,6 +116,13 @@ export const AccountDetailPage = () => { setBanPrivateNote(banResult.data.ban.privateNote ?? ""); } const altsResult = await api<{ alts: AltAccount[] }>( `/api/mod/user/${encodeURIComponent(sub)}/alts`, ); if (altsResult.status === 200 && altsResult.data.success) { setAlts(altsResult.data.alts); } setLoading(false); }; Loading Loading @@ -355,6 +379,75 @@ export const AccountDetailPage = () => { )} </div> {/* Alternate Accounts */} <div> <h4 className="text-lg font-semibold mb-2 flex items-center gap-2"> <Users size={18} /> Alternate Accounts </h4> {alts.length === 0 ? ( <span className="text-xs text-default-500"> No alternate accounts detected </span> ) : ( <div className="flex flex-col gap-3"> {alts.map((alt) => ( <div key={alt.sub} className="flex items-center gap-3 rounded-lg bg-default-100 p-3 cursor-pointer hover:bg-default-200 transition-colors" onClick={() => navigate( `/accounts/${encodeURIComponent(alt.sub)}`, ) } > <Avatar size="sm"> <Avatar.Image src={alt.picture_url ?? undefined} /> <Avatar.Fallback> {alt.username?.charAt(0)?.toUpperCase()} </Avatar.Fallback> </Avatar> <div className="flex flex-col min-w-0 flex-1"> <div className="flex items-center gap-2"> <span className="text-sm font-medium truncate"> {alt.username} </span> {alt.display_name && ( <span className="text-xs text-default-500 truncate"> ({alt.display_name}) </span> )} </div> <div className="flex flex-wrap gap-1 mt-1"> {alt.sharedCount > 0 && ( <span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/20 text-primary"> {alt.sharedCount} shared IP {alt.sharedCount !== 1 ? "s" : ""} </span> )} {alt.nearbyPixelCount > 0 && ( <span className="text-[10px] px-1.5 py-0.5 rounded bg-secondary/20 text-secondary"> <Radar size={10} className="inline mr-0.5" /> {alt.nearbyPixelCount} nearby pixel {alt.nearbyPixelCount !== 1 ? "s" : ""} </span> )} {alt.nameMatch !== "none" && ( <span className="text-[10px] px-1.5 py-0.5 rounded bg-warning/20 text-warning"> {alt.nameMatch === "exact" ? "Identical name" : "Similar name"} </span> )} </div> </div> </div> ))} </div> )} </div> {/* Send Notice */} <div> <h4 className="text-lg font-semibold mb-2 flex items-center gap-2"> Loading
packages/admin/src/pages/AltOverview/page.tsx 0 → 100644 +343 −0 Original line number Diff line number Diff line import { Breadcrumbs, BreadcrumbsItem, Button, ProgressCircle, Table, Tabs, } from "@heroui/react"; import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { api, handleError } from "../../lib/utils"; interface SampleUser { sub: string; username: string; display_name: string | null; } interface IPGroup { ip: string; accountCount: number; sampleUsers: SampleUser[]; } interface NameCluster { normalizedName: string; accountCount: number; users: SampleUser[]; } export const AltOverviewPage = () => { const navigate = useNavigate(); const [activeTab, setActiveTab] = useState<string>("ip"); // IP section state const [ips, setIPs] = useState<IPGroup[]>([]); const [ipLoading, setIPLoading] = useState(true); const [ipTotal, setIPTotal] = useState(0); const [ipPage, setIPPage] = useState(0); const [ipPageInput, setIPPageInput] = useState("1"); const ipPerPage = 20; // Name matches section state const [clusters, setClusters] = useState<NameCluster[]>([]); const [nameLoading, setNameLoading] = useState(true); const [nameTotal, setNameTotal] = useState(0); const [namePage, setNamePage] = useState(0); const [namePageInput, setNamePageInput] = useState("1"); const namePerPage = 20; const fetchIPs = async (skip: number) => { setIPLoading(true); const params = new URLSearchParams({ take: String(ipPerPage), skip: String(skip), }); const { status, data } = await api<{ ips: IPGroup[]; meta: { total: number }; }>(`/api/mod/user/alt/overview?${params}`); if (status === 200 && data.success) { setIPs(data.ips); setIPTotal(data.meta.total); } else { handleError(status, data); } setIPLoading(false); }; const fetchNameMatches = async (skip: number) => { setNameLoading(true); const params = new URLSearchParams({ take: String(namePerPage), skip: String(skip), }); const { status, data } = await api<{ clusters: NameCluster[]; meta: { total: number }; }>(`/api/mod/user/alt/name-matches?${params}`); if (status === 200 && data.success) { setClusters(data.clusters); setNameTotal(data.meta.total); } else { handleError(status, data); } setNameLoading(false); }; useEffect(() => { fetchIPs(ipPage * ipPerPage); }, [ipPage]); useEffect(() => { fetchNameMatches(namePage * namePerPage); }, [namePage]); const ipTotalPages = Math.ceil(ipTotal / ipPerPage); const nameTotalPages = Math.ceil(nameTotal / namePerPage); return ( <div className="my-14 lg:px-6 max-w-[95rem] mx-auto w-full flex flex-col gap-4"> <Breadcrumbs> <BreadcrumbsItem href="/">Home</BreadcrumbsItem> <BreadcrumbsItem>Alt Detection</BreadcrumbsItem> </Breadcrumbs> <h3 className="text-xl font-semibold">Alt Detection</h3> <p className="text-sm text-default-500"> Patterns that may indicate alternate accounts: IPs shared by many users, and usernames that differ only by look-alike characters. </p> <Tabs aria-label="Alt detection views" selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(key as string)} > <Tabs.List> <Tabs.Tab id="ip"> IP Overview <span className="text-xs text-default-400 ml-1">({ipTotal})</span> </Tabs.Tab> <Tabs.Tab id="names"> Name Matches <span className="text-xs text-default-400 ml-1">({nameTotal})</span> </Tabs.Tab> </Tabs.List> <Tabs.Panel id="ip"> <div className="flex flex-col gap-4 mt-4"> {ipLoading && <ProgressCircle className="self-center" />} <Table aria-label="IP alt overview"> <Table.ScrollContainer> <Table.Content> <Table.Header> <Table.Column isRowHeader>IP Address</Table.Column> <Table.Column>Accounts</Table.Column> <Table.Column>Sample Users</Table.Column> <Table.Column>Actions</Table.Column> </Table.Header> <Table.Body items={ips}> {(ip) => ( <Table.Row key={ip.ip}> <Table.Cell> <span className="font-mono text-sm">{ip.ip}</span> </Table.Cell> <Table.Cell> <span className="font-semibold"> {ip.accountCount} </span> </Table.Cell> <Table.Cell> <div className="flex flex-wrap gap-1"> {ip.sampleUsers.map((u) => ( <span key={u.sub} className="text-xs text-primary cursor-pointer hover:underline" onClick={() => navigate( `/accounts/${encodeURIComponent(u.sub)}`, ) } > {u.username} </span> ))} </div> </Table.Cell> <Table.Cell> <Button size="sm" variant="tertiary" onPress={() => navigate( `/accounts?search=${encodeURIComponent(ip.ip)}`, ) } > View matching accounts </Button> </Table.Cell> </Table.Row> )} </Table.Body> </Table.Content> </Table.ScrollContainer> </Table> {ipTotalPages > 1 && ( <div className="flex items-center justify-center gap-2"> <Button size="sm" isDisabled={ipPage === 0} onPress={() => setIPPage(ipPage - 1)} > Previous </Button> <span className="text-sm text-default-500"> Page {ipPage + 1} of {ipTotalPages} </span> <Button size="sm" isDisabled={ipPage >= ipTotalPages - 1} onPress={() => setIPPage(ipPage + 1)} > Next </Button> <span className="text-sm text-default-500 ml-2">Jump to</span> <input className="w-16 px-2 py-1 text-sm bg-default-100 rounded border border-default-200 text-center" value={ipPageInput} onChange={(e) => setIPPageInput(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === "Enter") { const p = parseInt(ipPageInput, 10); if (!isNaN(p) && p >= 1 && p <= ipTotalPages) setIPPage(p - 1); } }} /> </div> )} </div> </Tabs.Panel> <Tabs.Panel id="names"> <div className="flex flex-col gap-4 mt-4"> {nameLoading && <ProgressCircle className="self-center" />} {!nameLoading && nameTotal === 0 && ( <span className="text-sm text-default-500"> No name-match clusters found. </span> )} <Table aria-label="Name match clusters"> <Table.ScrollContainer> <Table.Content> <Table.Header> <Table.Column>Normalized Name</Table.Column> <Table.Column>Accounts</Table.Column> <Table.Column>Users</Table.Column> <Table.Column>Actions</Table.Column> </Table.Header> <Table.Body items={clusters}> {(cluster) => ( <Table.Row key={cluster.normalizedName}> <Table.Cell> <span className="font-mono text-sm"> {cluster.normalizedName} </span> </Table.Cell> <Table.Cell> <span className="font-semibold"> {cluster.accountCount} </span> </Table.Cell> <Table.Cell> <div className="flex flex-wrap gap-2"> {cluster.users.map((u) => ( <div key={u.sub} className="flex items-center gap-1.5 cursor-pointer hover:opacity-80" onClick={() => navigate( `/accounts/${encodeURIComponent(u.sub)}`, ) } > <span className="text-xs font-medium"> {u.username} </span> {u.display_name && ( <span className="text-[10px] text-default-500"> ({u.display_name}) </span> )} </div> ))} </div> </Table.Cell> <Table.Cell> <Button size="sm" variant="tertiary" onPress={() => navigate( `/accounts?search=${encodeURIComponent(cluster.normalizedName)}`, ) } > Search accounts </Button> </Table.Cell> </Table.Row> )} </Table.Body> </Table.Content> </Table.ScrollContainer> </Table> {nameTotalPages > 1 && ( <div className="flex items-center justify-center gap-2"> <Button size="sm" isDisabled={namePage === 0} onPress={() => setNamePage(namePage - 1)} > Previous </Button> <span className="text-sm text-default-500"> Page {namePage + 1} of {nameTotalPages} </span> <Button size="sm" isDisabled={namePage >= nameTotalPages - 1} onPress={() => setNamePage(namePage + 1)} > Next </Button> <span className="text-sm text-default-500 ml-2">Jump to</span> <input className="w-16 px-2 py-1 text-sm bg-default-100 rounded border border-default-200 text-center" value={namePageInput} onChange={(e) => setNamePageInput(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === "Enter") { const p = parseInt(namePageInput, 10); if (!isNaN(p) && p >= 1 && p <= nameTotalPages) setNamePage(p - 1); } }} /> </div> )} </div> </Tabs.Panel> </Tabs> </div> ); };
packages/server/src/__test__/lib/username.test.ts 0 → 100644 +128 −0 Original line number Diff line number Diff line import { levenshtein, nameSimilarity, normalizeUsername } from "../../lib/username"; describe("normalizeUsername", () => { it("lowercases and strips spaces", () => { expect(normalizeUsername(" Grant ")).toBe("grant"); }); it("replaces l with i", () => { expect(normalizeUsername("grantl")).toBe("granti"); }); it("replaces I with i", () => { expect(normalizeUsername("grantI")).toBe("granti"); }); it("replaces 1 with i", () => { expect(normalizeUsername("grant1")).toBe("granti"); }); it("replaces | with i", () => { expect(normalizeUsername("grant|")).toBe("granti"); }); it("replaces O -> 0 -> o, and l -> i", () => { // "hellO": lowercase -> "hello", l->i twice -> o unchanged -> "heiio" expect(normalizeUsername("hellO")).toBe("heiio"); }); it("replaces 0 -> o, and l -> i", () => { // "hell0": lowercase -> "hell0", l->i twice, 0->o -> "heiio" expect(normalizeUsername("hell0")).toBe("heiio"); }); it("replaces rn with m", () => { // "po_ta_rn": rn -> m -> "po_ta_m" expect(normalizeUsername("po_ta_rn")).toBe("po_ta_m"); }); it("handles empty string", () => { expect(normalizeUsername("")).toBe(""); }); it("handles strings with no look-alikes", () => { expect(normalizeUsername("xyz")).toBe("xyz"); }); it("combines multiple look-alikes", () => { // "Grantl|0": lowercase -> "grantl|0", l->i, |->i, 0->o -> "grantiio" expect(normalizeUsername("Grantl|0")).toBe("grantiio"); }); }); describe("levenshtein", () => { it("returns 0 for identical strings", () => { expect(levenshtein("hello", "hello")).toBe(0); }); it("returns length of string b when a is empty", () => { expect(levenshtein("", "abc")).toBe(3); }); it("returns length of string a when b is empty", () => { expect(levenshtein("abc", "")).toBe(3); }); it("returns 1 for single substitution", () => { expect(levenshtein("cat", "car")).toBe(1); }); it("returns 1 for single insertion", () => { expect(levenshtein("cat", "cats")).toBe(1); }); it("returns 1 for single deletion", () => { expect(levenshtein("cats", "cat")).toBe(1); }); it("returns correct distance for more complex edit", () => { expect(levenshtein("kitten", "sitting")).toBe(3); }); }); describe("nameSimilarity", () => { it("returns exact when normalized forms match", () => { // "grantl" and "grant1" both normalize to "granti" const result = nameSimilarity("grantl", "grant1"); expect(result.match).toBe("exact"); expect(result.distance).toBe(0); }); it("returns exact when look-alike I vs l", () => { // "admln" and "admin" — but note both normalize, so "admln" -> "admin" (l->i, i->i) // Actually "admin" stays "admin" (a,d,m,i,n — no look-alikes) // "admln" -> "admin" (l->i). So they'd be "exact" match. expect(nameSimilarity("admin", "admln").match).toBe("exact"); }); it("returns fuzzy for single-char difference", () => { const result = nameSimilarity("grant", "grants", 1); expect(result.match).toBe("fuzzy"); expect(result.distance).toBe(1); }); it("returns fuzzy for single substitution", () => { const result = nameSimilarity("grant", "grint", 1); expect(result.match).toBe("fuzzy"); expect(result.distance).toBe(1); }); it("returns none for strings with distance > maxDistance", () => { const result = nameSimilarity("grant", "completely", 1); expect(result.match).toBe("none"); expect(result.distance).toBeGreaterThan(1); }); it("respects custom maxDistance", () => { // "grant" vs "crunt" -> distance 2 (g->c, a->u) const result = nameSimilarity("grant", "crunt", 2); expect(result.match).toBe("fuzzy"); expect(result.distance).toBe(2); }); it("returns exact for identical strings with maxDistance 0", () => { const result = nameSimilarity("grant", "grant", 0); expect(result.match).toBe("exact"); expect(result.distance).toBe(0); }); }); No newline at end of file