added ability to check shared devices
This commit is contained in:
parent
7d0daef923
commit
72db66d4aa
14 changed files with 456 additions and 23 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue