Commit 150361d1 authored by Grant's avatar Grant
Browse files

admin: alt detection initial

parent c5a3c8be
Loading
Loading
Loading
Loading
Loading
+11 −1
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ import {
  Clock,
  House,
  List,
  Radar,
  Server,
  Settings,
  Square,
@@ -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({
@@ -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"
+6 −1
Original line number Diff line number Diff line
@@ -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";
@@ -25,6 +26,10 @@ const router = createBrowserRouter(
          path: "/",
          element: <HomePage />,
        },
        {
          path: "/alts",
          element: <AltOverviewPage />,
        },
        {
          path: "/accounts",
          element: <AccountsPage />,
+94 −1
Original line number Diff line number Diff line
@@ -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";
@@ -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();
@@ -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);
@@ -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);
    };

@@ -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">
+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>
  );
};
+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