Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -63,6 +64,8 @@ export default function AdminUserBasicInfo({
</div>
)}

<AdminUserIgnoreLoginData userId={user.user_id} />

<AdminUserRestrictButton user={user} />
<AdminUserResetPassword user={user} />
</CardContent>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="size-4 shrink-0" />
<span>No Register event found — cannot manage multi-account exemption.</span>
</div>
);
}

return (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label
htmlFor="ignore-login-data"
className="flex cursor-pointer items-center gap-2"
>
<Database className="size-4" />
Exempt from Multi-Account Check
</Label>
<p className="text-xs text-muted-foreground">
Ignore user's IP used for registration when checking for multi-account
</p>
</div>
<Switch
id="ignore-login-data"
checked={isIgnored}
onCheckedChange={handleToggle}
disabled={isMutating}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<Tooltip content={isHidden ? "Previous username is hidden" : "Hide previous username"}>
<div className="flex items-center gap-2 px-2">
<EyeOff className="size-4 text-muted-foreground" />
<Switch
checked={isHidden}
onCheckedChange={handleToggle}
disabled={isMutating}
/>
</div>
</Tooltip>
);
}
16 changes: 15 additions & 1 deletion app/(admin)/admin/users/components/AdminUserEventsColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand Down Expand Up @@ -183,4 +186,15 @@ export const adminUserEventsColumns: Array<ColumnDef<EventUserResponse>> = [
);
},
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => {
if (row.original.event_type !== UserEventType.CHANGE_USERNAME) {
return null;
}

return <AdminUserEventHidePreviousUsername event={row.original} />;
},
},
];
1 change: 1 addition & 0 deletions lib/hooks/api/score/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ export const ShortenedMods = {
[Mods.KEY3]: "K3",
[Mods.KEY2]: "K2",
[Mods.SCORE_V2]: "V2",
[Mods.MIRROR]: "MR",
};
38 changes: 38 additions & 0 deletions lib/hooks/api/user/useAdminUserEdit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import poster from "@/lib/services/poster";
import type {
CountryChangeRequest,
EditDescriptionRequest,
EditHidePreviousUsernameRequest,
EditIgnoreLoginDataRequest,
EditUserMetadataRequest,
EditUserPrivilegeRequest,
EditUserRestrictionRequest,
Expand Down Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions lib/types/api/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -558,6 +567,7 @@ export enum Mods {
KEY3 = "Key3",
KEY2 = "Key2",
SCORE_V2 = "ScoreV2",
MIRROR = "Mirror",
}

export type MostPlayedBeatmapResponse = {
Expand Down Expand Up @@ -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: {
Expand Down
Loading
Loading