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

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;