diff --git a/src/app/admin/players/page.tsx b/src/app/admin/players/page.tsx new file mode 100644 index 00000000..8fc916bf --- /dev/null +++ b/src/app/admin/players/page.tsx @@ -0,0 +1,12 @@ +import { type Metadata } from "next" +import { PlayerStandingDashboard } from "~/components/admin/players/player-standing-dashboard" + +export const dynamic = "force-dynamic" + +export const metadata: Metadata = { + title: "Player Standing" +} + +export default function Page() { + return +} diff --git a/src/app/admin/sidebar.tsx b/src/app/admin/sidebar.tsx index 3c7bc2d0..5ac8dce4 100644 --- a/src/app/admin/sidebar.tsx +++ b/src/app/admin/sidebar.tsx @@ -1,6 +1,14 @@ "use client" -import { Badge, BadgeCheck, Database, FileWarning, Settings, type LucideProps } from "lucide-react" +import { + Badge, + BadgeCheck, + Database, + FileWarning, + Settings, + UserSearch, + type LucideProps +} from "lucide-react" import Link from "next/link" import { usePathname } from "next/navigation" import { useSession } from "~/hooks/app/useSession" @@ -26,6 +34,11 @@ const adminRoutes: AdminRoute[] = [ path: "/reporting", Icon: FileWarning }, + { + name: "Player Standing", + path: "/players", + Icon: UserSearch + }, { name: "Badges", path: "/badges", diff --git a/src/components/admin/players/player-standing-dashboard.tsx b/src/components/admin/players/player-standing-dashboard.tsx new file mode 100644 index 00000000..97085b3b --- /dev/null +++ b/src/components/admin/players/player-standing-dashboard.tsx @@ -0,0 +1,437 @@ +"use client" + +import { useQueryClient } from "@tanstack/react-query" +import { Loader2, Search, TriangleAlert } from "lucide-react" +import Image from "next/image" +import Link from "next/link" +import { useState } from "react" +import { toast } from "sonner" +import { cn } from "~/lib/tw" +import type { InstancePlayerFlag, RaidHubPlayerStandingResponse } from "~/services/raidhub/types" +import { usePlayerStanding } from "~/services/raidhub/usePlayerStanding" +import { useRaidHubUpdatePlayer } from "~/services/raidhub/useRaidHubUpdatePlayer" +import { Button } from "~/shad/button" +import { Input } from "~/shad/input" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger +} from "~/shad/select" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/shad/table" +import { bungieIconUrl, getBungieDisplayName } from "~/util/destiny" +import { getRelativeTime } from "~/util/presentation/pastDates" +import { AdminPageHeader } from "../admin-page-header" +import { ReportPanelItemBox } from "../reporting/report-panel-item-box" + +const cheatLevelStrings = { + 0: "None", + 1: "Suspicious", + 2: "Moderate", + 3: "Extreme", + 4: "Blacklisted" +} + +const CheatFlags: Record = { + 33: "Bit 33", + 34: "Bit 34", + 35: "Bit 35", + 36: "Bit 36", + 37: "Bit 37", + 38: "Bit 38", + 39: "Bit 39", + 40: "Bit 40", + 41: "Bit 41", + 42: "Bit 42", + 43: "Bit 43", + 44: "Bit 44", + 45: "Bit 45", + 46: "Bit 46", + 47: "Bit 47", + 48: "Bit 48", + 49: "Bit 49", + 50: "Bit 50", + 51: "Bit 51", + 52: "Bit 52", + 53: "Solo", + 54: "Total Instance Kills", + 55: "Two Plus Cheaters", + 56: "Player Total Kills", + 57: "Player Weapon Diversity", + 58: "Player Super Kills", + 59: "Player Grenade Kills", + 60: "Too Fast", + 61: "Too Few Players Fresh", + 62: "Too Few Players Checkpoint" +} + +function getCheatCheckReasonsFromBitmask(bitmask: string): string[] { + const reasons: string[] = [] + const bitmaskNumber = BigInt(bitmask) + for (let i = 0; i < 64; i++) { + const flag = BigInt(1) << BigInt(i) + if ((bitmaskNumber & flag) === flag) { + const reason = CheatFlags[i] + if (reason) { + reasons.push(reason) + } + } + } + + if (reasons.length === 0) { + return ["Unspecified"] + } + + return reasons +} + +export function PlayerStandingDashboard() { + const [searchInput, setSearchInput] = useState("") + const [membershipId, setMembershipId] = useState(null) + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault() + if (searchInput.trim()) { + setMembershipId(searchInput.trim()) + } + } + + const { data, isLoading, error } = usePlayerStanding(membershipId) + + return ( +
+ + +
+ {/* Search Section */} +
+ setSearchInput(e.target.value)} + className="flex-1" + /> + +
+ + {/* Loading State */} + {isLoading && ( +
+ +
+ )} + + {/* Error State */} + {error && ( +
+

Error loading player data

+

{error.message}

+
+ )} + + {/* Empty State */} + {!membershipId && !isLoading && ( +
+ +

+ Enter a membership ID to get started +

+

+ Search for a player to view their standing and history +

+
+ )} + + {/* Player Standing Display */} + {data && !isLoading && ( +
+ + + +
+ )} +
+
+ ) +} + +function PlayerInfoCard({ + data, + membershipId +}: { + data: RaidHubPlayerStandingResponse + membershipId: string +}) { + const [selectedCheatLevel, setSelectedCheatLevel] = useState(data.playerInfo.cheatLevel) + const queryClient = useQueryClient() + + const updatePlayer = useRaidHubUpdatePlayer(membershipId, { + onSuccess: () => { + toast.success("Player updated successfully", { + description: `Player ${getBungieDisplayName(data.playerInfo)} updated` + }) + queryClient.setQueryData( + ["raidhub", "player-standing", membershipId], + old => { + if (!old) return old + return { + ...old, + playerInfo: { + ...old.playerInfo, + cheatLevel: selectedCheatLevel + } + } + } + ) + }, + onError: error => { + toast.error("Failed to update player", { + description: error.message + }) + } + }) + + const handleSave = () => { + updatePlayer.mutate({ + cheatLevel: selectedCheatLevel + }) + } + + return ( + +
+ {/* Player Header */} +
+
+ {data.playerInfo.iconPath && ( + Player icon + )} +
+

+ {getBungieDisplayName(data.playerInfo)} +

+ + {data.playerInfo.membershipId} + +
+
+ + {/* Cheat Level Selector */} +
+ + + {selectedCheatLevel !== data.playerInfo.cheatLevel && ( + + )} +
+
+ + {/* Player Details */} +
+
+ Last Seen: + {new Date(data.playerInfo.lastSeen).toLocaleString()} +
+
+ Private Profile: + {data.playerInfo.isPrivate ? "Yes" : "No"} +
+
+
+
+ ) +} + +function RecentFlagsTable({ + flags +}: { + flags: readonly (InstancePlayerFlag & { readonly instanceDate: string })[] +}) { + return ( + + {flags.length > 0 ? ( +
+ + + + Instance ID + Instance Date + Flagged At + Probability + Detection + Check Version + + + + {flags.map((flag, idx) => ( + + + + {flag.instanceId} + + + + {new Date(flag.instanceDate).toLocaleDateString()} + + + {getRelativeTime(new Date(flag.flaggedAt))} + + + 0.7 + ? "text-red-600" + : flag.cheatProbability > 0.4 + ? "text-red-400" + : flag.cheatProbability > 0.15 + ? "text-yellow-400" + : "text-green-400" + )}> + {(flag.cheatProbability * 100).toFixed(2)}% + + + + {getCheatCheckReasonsFromBitmask(flag.cheatCheckBitmask).join( + ", " + )} + + {flag.cheatCheckVersion} + + ))} + +
+
+ ) : ( +

No recent flags for this player

+ )} +
+ ) +} + +function BlacklistedInstancesTable({ + instances +}: { + instances: readonly { + readonly instanceId: string + readonly instanceDate: string + readonly reason: string + readonly individualReason: string | null + readonly createdAt: string + }[] +}) { + return ( + + {instances.length > 0 ? ( +
+ + + + Instance ID + Instance Date + Reason + Individual Reason + Blacklisted At + + + + {instances.map((instance, idx) => ( + + + + {instance.instanceId} + + + + {new Date(instance.instanceDate).toLocaleDateString()} + + {instance.reason} + + {instance.individualReason ?? "-"} + + + {new Date(instance.createdAt).toLocaleDateString()} + + + ))} + +
+
+ ) : ( +

No blacklisted instances for this player

+ )} +
+ ) +} diff --git a/src/services/raidhub/openapi.d.ts b/src/services/raidhub/openapi.d.ts index ce12a5de..5f61cdc9 100644 --- a/src/services/raidhub/openapi.d.ts +++ b/src/services/raidhub/openapi.d.ts @@ -2033,6 +2033,78 @@ export interface paths { }; }; }; + "/admin/reporting/player-standing/{membershipId}": { + /** + * /admin/reporting/player-standing/{membershipId} + * @description Get player standing information including recent flags and blacklisted instances. Requires authentication. + */ + get: { + parameters: { + path: { + membershipId: string; + }; + }; + responses: { + /** @description Success */ + 200: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: true; + readonly response: components["schemas"]["AdminPlayerStandingResponse"]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "ApiKeyError"; + readonly error: components["schemas"]["ApiKeyError"]; + }; + }; + }; + /** @description Not found */ + 404: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "PlayerNotFoundError"; + readonly error: components["schemas"]["PlayerNotFoundError"] & { + /** Format: int64 */ + readonly membershipId?: string; + }; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + content: { + readonly "application/json": { + /** Format: date-time */ + readonly minted: string; + /** @enum {boolean} */ + readonly success: false; + /** @enum {string} */ + readonly code: "InternalServerError"; + readonly error: components["schemas"]["InternalServerError"]; + }; + }; + }; + }; + }; + }; "/authorize/admin": { /** * /authorize/admin @@ -3223,6 +3295,23 @@ export interface components { readonly flags: readonly components["schemas"]["InstanceFlag"][]; readonly players: readonly components["schemas"]["InstancePlayerStanding"][]; }; + readonly AdminPlayerStandingResponse: { + readonly playerInfo: components["schemas"]["PlayerInfo"]; + readonly recentFlags: readonly (components["schemas"]["InstancePlayerFlag"] & { + /** Format: date-time */ + readonly instanceDate: string; + })[]; + readonly blacklistedInstances: readonly ({ + /** Format: int64 */ + readonly instanceId: string; + /** Format: date-time */ + readonly instanceDate: string; + readonly reason: string; + readonly individualReason: string | null; + /** Format: date-time */ + readonly createdAt: string; + })[]; + }; readonly AdminReportingBlacklistResponse: { readonly blacklisted: boolean; }; diff --git a/src/services/raidhub/types.ts b/src/services/raidhub/types.ts index 80586e0f..4d435f1e 100644 --- a/src/services/raidhub/types.ts +++ b/src/services/raidhub/types.ts @@ -110,6 +110,7 @@ export type RaidHubMetricsWeaponsRollingWeekResponse = export type RaidHubMetricsPopulationRollingDayResponse = Component<"MetricsPopulationRollingDayResponse"> export type RaidHubInstanceStandingResponse = Component<"AdminReportingStandingResponse"> +export type RaidHubPlayerStandingResponse = Component<"AdminPlayerStandingResponse"> interface GetSchema { get: { diff --git a/src/services/raidhub/usePlayerStanding.ts b/src/services/raidhub/usePlayerStanding.ts new file mode 100644 index 00000000..3eeebdb8 --- /dev/null +++ b/src/services/raidhub/usePlayerStanding.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query" +import { useSession } from "~/hooks/app/useSession" +import { getRaidHubApi } from "./common" + +export const usePlayerStanding = (membershipId: string | null) => { + const session = useSession() + const accessToken = session.data?.raidHubAccessToken + return useQuery({ + queryKey: ["raidhub", "player-standing", membershipId], + queryFn: () => + getRaidHubApi( + "/admin/reporting/player-standing/{membershipId}", + { + membershipId: membershipId! + }, + null, + { + headers: accessToken + ? { + Authorization: `Bearer ${accessToken.value}` + } + : {} + } + ).then(res => res.response), + enabled: !!membershipId + }) +}