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({