added ability to check shared devices
This commit is contained in:
parent
7d0daef923
commit
72db66d4aa
14 changed files with 456 additions and 23 deletions
|
|
@ -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}`),
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
131
cmd/telesrv-admin/web/src/pages/SharedDevicesPage.tsx
Normal file
131
cmd/telesrv-admin/web/src/pages/SharedDevicesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue