diff --git a/app/(admin)/admin/users/[id]/edit/components/AdminUserBasicInfo.tsx b/app/(admin)/admin/users/[id]/edit/components/AdminUserBasicInfo.tsx index f9686db..8c893cd 100644 --- a/app/(admin)/admin/users/[id]/edit/components/AdminUserBasicInfo.tsx +++ b/app/(admin)/admin/users/[id]/edit/components/AdminUserBasicInfo.tsx @@ -3,6 +3,7 @@ import { Mail, User } from "lucide-react"; import AdminUserEmailInput from "@/app/(admin)/admin/users/[id]/edit/components/AdminUserEmailInput"; +import AdminUserIgnoreLoginData from "@/app/(admin)/admin/users/[id]/edit/components/AdminUserIgnoreLoginData"; import AdminUserPrivilegeInput from "@/app/(admin)/admin/users/[id]/edit/components/AdminUserPrivilegeInput"; import AdminUserResetPassword from "@/app/(admin)/admin/users/[id]/edit/components/AdminUserResetPassword"; import AdminUserRestrictButton from "@/app/(admin)/admin/users/[id]/edit/components/AdminUserRestrictButton"; @@ -63,6 +64,8 @@ export default function AdminUserBasicInfo({ )} + + diff --git a/app/(admin)/admin/users/[id]/edit/components/AdminUserIgnoreLoginData.tsx b/app/(admin)/admin/users/[id]/edit/components/AdminUserIgnoreLoginData.tsx new file mode 100644 index 0000000..bfb4708 --- /dev/null +++ b/app/(admin)/admin/users/[id]/edit/components/AdminUserIgnoreLoginData.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { AlertCircle, Database } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/hooks/use-toast"; +import { + useAdminEditIgnoreLoginData, + useUserEvents, +} from "@/lib/hooks/api/user/useAdminUserEdit"; +import { UserEventType } from "@/lib/types/api"; + +function parseIsIgnored(jsonData: string): boolean { + try { + const parsed = JSON.parse(jsonData); + return parsed?.IsExemptFromMultiaccountCheck ?? false; + } + catch { + return false; + } +} + +export default function AdminUserIgnoreLoginData({ + userId, +}: { + userId: number; +}) { + const { toast } = useToast(); + + // TODO: This is actually an oversight from the backend, since getting the initial value from the register event is really hacky. + const { data: registerEvents, isLoading } = useUserEvents( + userId, + null, + 1, + 1, + [UserEventType.REGISTER], + ); + + const { trigger: editIgnoreLoginData, isMutating } + = useAdminEditIgnoreLoginData(userId); + + const registerEvent = registerEvents?.events?.[0]; + const serverIsIgnored = registerEvent + ? parseIsIgnored(registerEvent.json_data) + : false; + + const [isIgnored, setIsIgnored] = useState(serverIsIgnored); + + useEffect(() => { + setIsIgnored(serverIsIgnored); + }, [serverIsIgnored]); + + const handleToggle = async (checked: boolean) => { + setIsIgnored(checked); + try { + await editIgnoreLoginData({ is_ignored: checked }); + toast({ + title: checked + ? "Multi-account check exemption enabled" + : "Multi-account check exemption disabled", + variant: "success", + }); + } + catch (error: any) { + setIsIgnored(serverIsIgnored); + toast({ + title: "Failed to update multi-account exemption", + description: error?.message || "An error occurred", + variant: "destructive", + }); + } + }; + + if (isLoading) + return null; + + if (!registerEvent) { + return ( +
+ + No Register event found — cannot manage multi-account exemption. +
+ ); + } + + return ( +
+
+ +

+ Ignore user's IP used for registration when checking for multi-account +

+
+ +
+ ); +} diff --git a/app/(admin)/admin/users/components/AdminUserEventHidePreviousUsername.tsx b/app/(admin)/admin/users/components/AdminUserEventHidePreviousUsername.tsx new file mode 100644 index 0000000..a44d18b --- /dev/null +++ b/app/(admin)/admin/users/components/AdminUserEventHidePreviousUsername.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { EyeOff } from "lucide-react"; +import { useState } from "react"; + +import { Tooltip } from "@/components/Tooltip"; +import { Switch } from "@/components/ui/switch"; +import { useToast } from "@/hooks/use-toast"; +import { useAdminEditHidePreviousUsername } from "@/lib/hooks/api/user/useAdminUserEdit"; +import type { EventUserResponse } from "@/lib/types/api"; +import { UserEventType } from "@/lib/types/api"; + +function parseIsHidden(jsonData: string): boolean { + try { + const parsed = JSON.parse(jsonData); + return parsed?.IsHiddenFromPreviousUsernames ?? false; + } + catch { + return false; + } +} + +export default function AdminUserEventHidePreviousUsername({ + event, +}: { + event: EventUserResponse; +}) { + const { toast } = useToast(); + const { trigger: editHidePreviousUsername, isMutating } + = useAdminEditHidePreviousUsername(event.user.user_id); + + const serverIsHidden = parseIsHidden(event.json_data); + const [isHidden, setIsHidden] = useState(serverIsHidden); + + if (event.event_type !== UserEventType.CHANGE_USERNAME) { + return null; + } + + const handleToggle = async (checked: boolean) => { + setIsHidden(checked); + try { + await editHidePreviousUsername({ + event_id: event.id, + is_hidden: checked, + }); + toast({ + title: checked + ? "Previous username is now hidden" + : "Previous username is now visible", + variant: "success", + }); + } + catch (error: any) { + setIsHidden(serverIsHidden); + toast({ + title: "Failed to update previous username visibility", + description: error?.message || "An error occurred", + variant: "destructive", + }); + } + }; + + return ( + +
+ + +
+
+ ); +} diff --git a/app/(admin)/admin/users/components/AdminUserEventsColumns.tsx b/app/(admin)/admin/users/components/AdminUserEventsColumns.tsx index a836650..aaabd2e 100644 --- a/app/(admin)/admin/users/components/AdminUserEventsColumns.tsx +++ b/app/(admin)/admin/users/components/AdminUserEventsColumns.tsx @@ -6,9 +6,12 @@ import { SortAsc, SortDesc } from "lucide-react"; import { Tooltip } from "@/components/Tooltip"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import type { EventUserResponse, UserEventType } from "@/lib/types/api"; +import type { EventUserResponse } from "@/lib/types/api"; +import { UserEventType } from "@/lib/types/api"; import { timeSince } from "@/lib/utils/timeSince"; +import AdminUserEventHidePreviousUsername from "./AdminUserEventHidePreviousUsername"; + function hashStringToHue(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { @@ -183,4 +186,15 @@ export const adminUserEventsColumns: Array> = [ ); }, }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => { + if (row.original.event_type !== UserEventType.CHANGE_USERNAME) { + return null; + } + + return ; + }, + }, ]; diff --git a/lib/hooks/api/score/types.ts b/lib/hooks/api/score/types.ts index 4715189..49a10ef 100644 --- a/lib/hooks/api/score/types.ts +++ b/lib/hooks/api/score/types.ts @@ -32,4 +32,5 @@ export const ShortenedMods = { [Mods.KEY3]: "K3", [Mods.KEY2]: "K2", [Mods.SCORE_V2]: "V2", + [Mods.MIRROR]: "MR", }; diff --git a/lib/hooks/api/user/useAdminUserEdit.ts b/lib/hooks/api/user/useAdminUserEdit.ts index e1389fb..ebfbf19 100644 --- a/lib/hooks/api/user/useAdminUserEdit.ts +++ b/lib/hooks/api/user/useAdminUserEdit.ts @@ -7,6 +7,8 @@ import poster from "@/lib/services/poster"; import type { CountryChangeRequest, EditDescriptionRequest, + EditHidePreviousUsernameRequest, + EditIgnoreLoginDataRequest, EditUserMetadataRequest, EditUserPrivilegeRequest, EditUserRestrictionRequest, @@ -199,6 +201,42 @@ export function useAdminEditPrivilege(userId: number) { ); } +export function useAdminEditIgnoreLoginData(userId: number) { + return useSWRMutation( + `user/${userId}/ignore-login-data`, + async (url: string, { arg }: { arg: EditIgnoreLoginDataRequest }) => { + const result = await poster(`user/${userId}/edit/ignore-login-data`, { + json: arg, + }); + + mutate( + (key: string) => + typeof key === "string" + && key.startsWith(`user/${userId}/events`), + ); + return result; + }, + ); +} + +export function useAdminEditHidePreviousUsername(userId: number) { + return useSWRMutation( + `user/edit/hide-previous-username/${userId}`, + async (url: string, { arg }: { arg: EditHidePreviousUsernameRequest }) => { + const result = await poster(`user/edit/hide-previous-username`, { + json: arg, + }); + + mutate( + (key: string) => + typeof key === "string" + && key.startsWith(`user/${userId}/events`), + ); + return result; + }, + ); +} + export function useUserEvents( userId: number, query: string | null, diff --git a/lib/types/api/types.gen.ts b/lib/types/api/types.gen.ts index 2f31901..2077dd1 100644 --- a/lib/types/api/types.gen.ts +++ b/lib/types/api/types.gen.ts @@ -388,6 +388,15 @@ export type EditFriendshipStatusRequest = { action: UpdateFriendshipStatusAction; }; +export type EditHidePreviousUsernameRequest = { + event_id: number; + is_hidden: boolean; +}; + +export type EditIgnoreLoginDataRequest = { + is_ignored: boolean; +}; + export type EditUserMetadataRequest = { playstyle?: UserPlaystyle[] | null; location?: string | null; @@ -558,6 +567,7 @@ export enum Mods { KEY3 = "Key3", KEY2 = "Key2", SCORE_V2 = "ScoreV2", + MIRROR = "Mirror", } export type MostPlayedBeatmapResponse = { @@ -1998,6 +2008,66 @@ export type PostUserByIdEditRestrictionResponses = { 200: unknown; }; +export type PostUserByIdEditIgnoreLoginDataData = { + body?: EditIgnoreLoginDataRequest; + path: { + id: number; + }; + query?: never; + url: "/user/{id}/edit/ignore-login-data"; +}; + +export type PostUserByIdEditIgnoreLoginDataErrors = { + /** + * Bad Request + */ + 400: ProblemDetailsResponseType; + /** + * Unauthorized + */ + 401: ProblemDetailsResponseType; + /** + * Not Found + */ + 404: ProblemDetailsResponseType; +}; + +export type PostUserByIdEditIgnoreLoginDataError = PostUserByIdEditIgnoreLoginDataErrors[keyof PostUserByIdEditIgnoreLoginDataErrors]; + +export type PostUserByIdEditIgnoreLoginDataResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type PostUserEditHidePreviousUsernameData = { + body?: EditHidePreviousUsernameRequest; + path?: never; + query?: never; + url: "/user/edit/hide-previous-username"; +}; + +export type PostUserEditHidePreviousUsernameErrors = { + /** + * Bad Request + */ + 400: ProblemDetailsResponseType; + /** + * Unauthorized + */ + 401: ProblemDetailsResponseType; +}; + +export type PostUserEditHidePreviousUsernameError = PostUserEditHidePreviousUsernameErrors[keyof PostUserEditHidePreviousUsernameErrors]; + +export type PostUserEditHidePreviousUsernameResponses = { + /** + * OK + */ + 200: unknown; +}; + export type GetUserByUserIdGraphData = { body?: never; path: { diff --git a/lib/types/api/zod.gen.ts b/lib/types/api/zod.gen.ts index 17c7e90..44465e9 100644 --- a/lib/types/api/zod.gen.ts +++ b/lib/types/api/zod.gen.ts @@ -558,6 +558,15 @@ export const zEditFriendshipStatusRequest = z.object({ action: zUpdateFriendshipStatusAction, }); +export const zEditHidePreviousUsernameRequest = z.object({ + event_id: z.number().int().gte(1).lte(2147483647), + is_hidden: z.boolean(), +}); + +export const zEditIgnoreLoginDataRequest = z.object({ + is_ignored: z.boolean(), +}); + export const zUserPlaystyle = z.enum([ "None", "Mouse", @@ -815,6 +824,7 @@ export const zMods = z.enum([ "Key3", "Key2", "ScoreV2", + "Mirror", ]); export const zMostPlayedBeatmapResponse = z.object({