added ability to check shared devices

This commit is contained in:
onysd 2026-08-06 02:12:46 +03:00
parent 7d0daef923
commit 72db66d4aa
14 changed files with 456 additions and 23 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,7 +23,7 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-Defd2Shn.js"></script>
<script type="module" crossorigin src="/assets/index-CvaJc5Uc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C7vmH2zd.css">
</head>
<body>

View file

@ -5,6 +5,7 @@ import type {
AccountRatingListResponse,
AccountStatsResponse,
AccountStorageListResponse,
SharedDeviceGroupListResponse,
StorageStatsResponse,
AdminLoginResult,
AdminSession,
@ -154,6 +155,7 @@ export const api = {
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
sharedDeviceGroups: (params: URLSearchParams) => request<SharedDeviceGroupListResponse>(`/api/accounts/shared-devices?${params.toString()}`),
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
channels: (params: URLSearchParams) => request<ChannelListResponse>(`/api/channels?${params.toString()}`),
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),

View file

@ -1,4 +1,4 @@
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search, Smartphone } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Avatar } from "../components/Avatar";
@ -99,17 +99,22 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
title={"Accounts"}
eyebrow={data?.listing === false ? "Search results" : "Recently active accounts"}
actions={
<button
className="btn"
type="button"
onClick={() => {
void loadFresh();
void loadStats();
}}
disabled={busy}
>
<RefreshCw size={15} /> {"Refresh"}
</button>
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/accounts/shared-devices")}>
<Smartphone size={15} /> {"Shared devices"}
</button>
<button
className="btn"
type="button"
onClick={() => {
void loadFresh();
void loadStats();
}}
disabled={busy}
>
<RefreshCw size={15} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}

View file

@ -3,6 +3,7 @@ import { AccountDetailPage } from "./AccountDetailPage";
import { AccountRatingDetailPage } from "./AccountRatingDetailPage";
import { AccountRatingsPage } from "./AccountRatingsPage";
import { AccountsPage } from "./AccountsPage";
import { SharedDevicesPage } from "./SharedDevicesPage";
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
import { ChannelDetailPage } from "./ChannelDetailPage";
@ -100,6 +101,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (moderationCaseID) {
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
}
if (route.path === "/accounts/shared-devices") {
return <SharedDevicesPage navigate={navigate} />;
}
if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />;
}

View file

@ -0,0 +1,131 @@
import { ArrowLeft, ChevronDown, ChevronRight, Loader2, RefreshCw, Smartphone } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Avatar } from "../components/Avatar";
import { Alert, Badge, EmptyRow, Metric, PageFrame, SectionHead } from "../components/ui";
import { displayName, displayPhone, displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { SharedDeviceGroup } from "../types";
// SharedDevicesPage surfaces authorizations that look like they came from the
// same physical device but belong to different accounts -- a lead worth
// investigating for multi-accounting, not a verdict: device_model and
// system_version are self-reported by the client and easy to spoof, and a
// matching IP alone is common and innocent behind NAT, shared wifi, or
// carrier CGNAT. Treat every group here as "worth a look", not "guilty".
export function SharedDevicesPage({ navigate }: { navigate: Navigate }) {
const [groups, setGroups] = useState<SharedDeviceGroup[]>([]);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const at = next ? offset : 0;
const params = new URLSearchParams({ limit: "20", offset: String(at) });
try {
const result = await api.sharedDeviceGroups(params);
const page = result.rows ?? [];
setGroups((current) => (next ? [...current, ...page] : page));
setOffset(result.next_offset);
setHasMore(Boolean(result.has_more));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const totalFlaggedAccounts = groups.reduce((sum, group) => sum + group.AccountCount, 0);
return (
<PageFrame
title={"Shared Devices"}
eyebrow={"Multi-account signal — device/IP overlap across different accounts"}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/accounts")}>
<ArrowLeft size={15} /> {"Back to accounts"}
</button>
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Device groups on page"} value={String(groups.length)} />
<Metric label={"Accounts flagged on page"} value={String(totalFlaggedAccounts)} tone="warn" />
</div>
<p className="about-text">
{"Each card below is a device fingerprint (device model + OS + platform + IP) that more than one account has authorized from. "}
{"device_model/system_version are self-reported by the client, and IP alone can collide innocently -- use this as a lead, not a verdict."}
</p>
<div className="stacked-sections">
{groups.map((group) => (
<section className="section-block" key={`${group.DeviceModel}|${group.SystemVersion}|${group.Platform}|${group.IP}`}>
<SectionHead
title={group.DeviceModel || "Unknown device"}
text={`${group.Platform || "unknown platform"} ${group.SystemVersion} · ${group.IP} · last active ${formatDate(group.LastActiveAt)}`}
action={<Badge tone="warn"><Smartphone size={12} /> {`${group.AccountCount} accounts`}</Badge>}
/>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th className="avatar-col"></th>
<th>{"User ID"}</th>
<th>{"Phone"}</th>
<th>{"Username"}</th>
<th>{"Name"}</th>
<th>{"Active from this device"}</th>
<th></th>
</tr>
</thead>
<tbody>
{group.Accounts.map((account) => (
<tr key={account.UserID}>
<td className="avatar-col"><Avatar id={account.UserID} firstName={account.FirstName} lastName={account.LastName} username={account.Username} /></td>
<td className="mono">{account.UserID}</td>
<td>{displayPhone(account.Phone)}</td>
<td>{displayUsername(account.Username) || "-"}</td>
<td>{displayName(account) || "-"}</td>
<td>{formatDate(account.ActiveAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${account.UserID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
{groups.length === 0 && (
<div className="table-wrap">
<table className="data-table">
<tbody>
<EmptyRow colSpan={7} />
</tbody>
</table>
</div>
)}
</div>
{hasMore && (
<div className="toolbar">
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {"Load more"}
</button>
</div>
)}
</PageFrame>
);
}

View file

@ -22,6 +22,7 @@ export function routeTitle(pathname: string): string {
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
if (pathname.startsWith("/account-ratings")) return "Account Rating";
if (pathname.startsWith("/storage")) return "Storage";
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
if (pathname.startsWith("/accounts")) return "Accounts";
if (pathname.startsWith("/channels")) return "Supergroups and Channels";
if (pathname.startsWith("/bots")) return "Bots";

View file

@ -789,6 +789,39 @@ export type AccountStatsResponse = {
online: number;
};
// SharedDeviceAccount is one account whose authorizations matched a
// SharedDeviceGroup's device fingerprint.
export type SharedDeviceAccount = {
UserID: number;
Phone: string;
Username: string;
FirstName: string;
LastName: string;
ActiveAt: string;
};
// SharedDeviceGroup is a device fingerprint (device model + OS + platform +
// IP) shared by more than one distinct account -- a heuristic multi-account
// signal, not proof (device_model/system_version are client-reported and
// spoofable, and IP alone collides behind NAT/shared wifi/carrier CGNAT).
export type SharedDeviceGroup = {
DeviceModel: string;
SystemVersion: string;
Platform: string;
IP: string;
AccountCount: number;
LastActiveAt: string;
Accounts: SharedDeviceAccount[];
};
export type SharedDeviceGroupListResponse = {
limit: number;
offset: number;
rows: SharedDeviceGroup[];
has_more: boolean;
next_offset: number;
};
export type StorageStatsResponse = {
PhysicalBytes: string;
LogicalBytes: string;