From 6b57699f314cbac1605a063b7b2abca195e0056c Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sat, 14 Mar 2026 00:01:47 +0200 Subject: [PATCH 1/5] feat: api gen --- lib/types/api/types.gen.ts | 70 ++++++++++++++++++++++++++++++++++++++ lib/types/api/zod.gen.ts | 10 ++++++ 2 files changed, 80 insertions(+) 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({ From ec9e92deced8f7dc644bc9ca2a1c256bd5913b9b Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sat, 14 Mar 2026 00:07:34 +0200 Subject: [PATCH 2/5] feat: Add ability to hide username change --- .../AdminUserEventHidePreviousUsername.tsx | 75 +++++++++++++++++++ .../components/AdminUserEventsColumns.tsx | 16 +++- lib/hooks/api/user/useAdminUserEdit.ts | 42 +++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 app/(admin)/admin/users/components/AdminUserEventHidePreviousUsername.tsx 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/user/useAdminUserEdit.ts b/lib/hooks/api/user/useAdminUserEdit.ts index e1389fb..9827c80 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,46 @@ 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`), + undefined, + { revalidate: true }, + ); + 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`), + undefined, + { revalidate: true }, + ); + return result; + }, + ); +} + export function useUserEvents( userId: number, query: string | null, From 2cedd2668174cc8397796720f03c2d4616185607 Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sat, 14 Mar 2026 00:37:28 +0200 Subject: [PATCH 3/5] feat: Add ability to ignore login data --- .../edit/components/AdminUserBasicInfo.tsx | 3 + .../components/AdminUserIgnoreLoginData.tsx | 104 ++++++++++++++++++ lib/hooks/api/user/useAdminUserEdit.ts | 4 - 3 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 app/(admin)/admin/users/[id]/edit/components/AdminUserIgnoreLoginData.tsx 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..041dbba --- /dev/null +++ b/app/(admin)/admin/users/[id]/edit/components/AdminUserIgnoreLoginData.tsx @@ -0,0 +1,104 @@ +"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(); + + 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 + ? "Login data is now ignored" + : "Login data is no longer ignored", + variant: "success", + }); + } + catch (error: any) { + setIsIgnored(serverIsIgnored); + toast({ + title: "Failed to update ignore login data", + description: error?.message || "An error occurred", + variant: "destructive", + }); + } + }; + + if (isLoading) + return null; + + if (!registerEvent) { + return ( + + + No Register event found — cannot manage login data ignore setting. + + ); + } + + return ( + + + + Ignore Login Data + + + + ); +} diff --git a/lib/hooks/api/user/useAdminUserEdit.ts b/lib/hooks/api/user/useAdminUserEdit.ts index 9827c80..ebfbf19 100644 --- a/lib/hooks/api/user/useAdminUserEdit.ts +++ b/lib/hooks/api/user/useAdminUserEdit.ts @@ -213,8 +213,6 @@ export function useAdminEditIgnoreLoginData(userId: number) { (key: string) => typeof key === "string" && key.startsWith(`user/${userId}/events`), - undefined, - { revalidate: true }, ); return result; }, @@ -233,8 +231,6 @@ export function useAdminEditHidePreviousUsername(userId: number) { (key: string) => typeof key === "string" && key.startsWith(`user/${userId}/events`), - undefined, - { revalidate: true }, ); return result; }, From dab7ee7ee48edb44b92d4fb2837d6cd21de77d2d Mon Sep 17 00:00:00 2001 From: richardscull <106016833+richardscull@users.noreply.github.com> Date: Sat, 14 Mar 2026 00:46:51 +0200 Subject: [PATCH 4/5] feat: Improve exempt from multi account check wording --- .../components/AdminUserIgnoreLoginData.tsx | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/app/(admin)/admin/users/[id]/edit/components/AdminUserIgnoreLoginData.tsx b/app/(admin)/admin/users/[id]/edit/components/AdminUserIgnoreLoginData.tsx index 041dbba..bfb4708 100644 --- a/app/(admin)/admin/users/[id]/edit/components/AdminUserIgnoreLoginData.tsx +++ b/app/(admin)/admin/users/[id]/edit/components/AdminUserIgnoreLoginData.tsx @@ -29,6 +29,7 @@ export default function AdminUserIgnoreLoginData({ }) { 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, @@ -57,15 +58,15 @@ export default function AdminUserIgnoreLoginData({ await editIgnoreLoginData({ is_ignored: checked }); toast({ title: checked - ? "Login data is now ignored" - : "Login data is no longer ignored", + ? "Multi-account check exemption enabled" + : "Multi-account check exemption disabled", variant: "success", }); } catch (error: any) { setIsIgnored(serverIsIgnored); toast({ - title: "Failed to update ignore login data", + title: "Failed to update multi-account exemption", description: error?.message || "An error occurred", variant: "destructive", }); @@ -79,20 +80,25 @@ export default function AdminUserIgnoreLoginData({ return ( - No Register event found — cannot manage login data ignore setting. + No Register event found — cannot manage multi-account exemption. ); } return ( - - - - Ignore Login Data - + + + + + Exempt from Multi-Account Check + + + Ignore user's IP used for registration when checking for multi-account + + Date: Sat, 14 Mar 2026 00:48:25 +0200 Subject: [PATCH 5/5] feat: fix types for shortened mods --- lib/hooks/api/score/types.ts | 1 + 1 file changed, 1 insertion(+) 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", };
+ Ignore user's IP used for registration when checking for multi-account +