Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b533ac2732 |
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=https://tapi.hifast.biz
|
||||||
@@ -16,11 +16,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@workspace/ui/components/dropdown-menu";
|
} from "@workspace/ui/components/dropdown-menu";
|
||||||
import { Input } from "@workspace/ui/components/input";
|
import { Input } from "@workspace/ui/components/input";
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@workspace/ui/components/popover";
|
|
||||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -36,6 +31,13 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@workspace/ui/components/sheet";
|
} from "@workspace/ui/components/sheet";
|
||||||
|
import { FilePenLine } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverClose,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@workspace/ui/components/popover';
|
||||||
import { Switch } from "@workspace/ui/components/switch";
|
import { Switch } from "@workspace/ui/components/switch";
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -61,16 +63,13 @@ import {
|
|||||||
updateUserBasicInfo,
|
updateUserBasicInfo,
|
||||||
} from "@workspace/ui/services/admin/user";
|
} from "@workspace/ui/services/admin/user";
|
||||||
import { parseDeviceType } from "@workspace/ui/utils/device";
|
import { parseDeviceType } from "@workspace/ui/utils/device";
|
||||||
import { FilePenLine } from "lucide-react";
|
import React, { useRef, useState, useCallback } from 'react';
|
||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Display } from "@/components/display";
|
import { Display } from "@/components/display";
|
||||||
import { useSubscribe } from "@/stores/subscribe";
|
import { useSubscribe } from "@/stores/subscribe";
|
||||||
import { formatDate } from "@/utils/common";
|
import { formatDate } from "@/utils/common";
|
||||||
import FamilyManagement from "./family";
|
import FamilyManagement from "./family";
|
||||||
import { RemarkForm } from "./remark-form";
|
|
||||||
import { buildUserBasicInfoPayload } from "./user-basic-info-payload";
|
|
||||||
import { UserDetail } from "./user-detail";
|
import { UserDetail } from "./user-detail";
|
||||||
import UserForm from "./user-form";
|
import UserForm from "./user-form";
|
||||||
import { UserInviteStatsSheet } from "./user-invite-stats-sheet";
|
import { UserInviteStatsSheet } from "./user-invite-stats-sheet";
|
||||||
@@ -78,11 +77,44 @@ import { AuthMethodsForm } from "./user-profile/auth-methods-form";
|
|||||||
import { BasicInfoForm } from "./user-profile/basic-info-form";
|
import { BasicInfoForm } from "./user-profile/basic-info-form";
|
||||||
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
|
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
|
||||||
import UserSubscription from "./user-subscription";
|
import UserSubscription from "./user-subscription";
|
||||||
|
|
||||||
// import EditUserGroupDialog from "./edit-user-group-dialog";
|
// import EditUserGroupDialog from "./edit-user-group-dialog";
|
||||||
|
|
||||||
type UserDeviceWithDeviceNo = API.UserDevice & {
|
|
||||||
device_no?: string;
|
// 为 RemarkForm 组件定义 props 类型
|
||||||
|
interface RemarkFormProps {
|
||||||
|
initialRemark?: string | null;
|
||||||
|
onSave: (remark: string) => void;
|
||||||
|
CloseComponent: React.ComponentType<{ asChild?: boolean; children: React.ReactNode }>;
|
||||||
|
}
|
||||||
|
// 新的子组件,在管理它自己的备注状态
|
||||||
|
const RemarkForm: React.FC<RemarkFormProps> = ({ onSave, initialRemark, CloseComponent }) => {
|
||||||
|
const [remark, setRemark] = useState<string>(initialRemark ?? '');
|
||||||
|
|
||||||
|
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setRemark(event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveClick = () => {
|
||||||
|
onSave(remark);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='mb-2 text-sm font-semibold'>备注</div>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
value={remark}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder='在此输入备注...'
|
||||||
|
className='w-full'
|
||||||
|
/>
|
||||||
|
<CloseComponent asChild>
|
||||||
|
<Button onClick={handleSaveClick} variant='default' size={'sm'} className={'mt-2'}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</CloseComponent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function User() {
|
export default function User() {
|
||||||
@@ -152,7 +184,7 @@ export default function User() {
|
|||||||
userId={row.id}
|
userId={row.id}
|
||||||
/>,
|
/>,
|
||||||
<PreviewNodesDialog key="preview-nodes" userId={row.id} />,
|
<PreviewNodesDialog key="preview-nodes" userId={row.id} />,
|
||||||
/* <ConfirmButton
|
/* <ConfirmButton
|
||||||
cancelText={t("cancel", "Cancel")}
|
cancelText={t("cancel", "Cancel")}
|
||||||
confirmText={t("confirm", "Confirm")}
|
confirmText={t("confirm", "Confirm")}
|
||||||
description={t(
|
description={t(
|
||||||
@@ -261,57 +293,61 @@ export default function User() {
|
|||||||
{
|
{
|
||||||
id: "auth_methods",
|
id: "auth_methods",
|
||||||
accessorKey: "auth_methods",
|
accessorKey: "auth_methods",
|
||||||
header: "设备码/邮箱",
|
header: '设备码/邮箱',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const method = row.original.auth_methods?.[0];
|
const method = row.original.auth_methods?.[0];
|
||||||
const identifier = method?.auth_identifier || "";
|
const identifier = method?.auth_identifier || "";
|
||||||
const isDevice = method?.auth_type === "device";
|
const isDevice = method?.auth_type === "device";
|
||||||
const firstDevice = row.original.user_devices?.[0] as
|
const firstDevice = row.original.user_devices?.[0] as any;
|
||||||
| UserDeviceWithDeviceNo
|
|
||||||
| undefined;
|
|
||||||
const deviceNo = firstDevice?.device_no;
|
const deviceNo = firstDevice?.device_no;
|
||||||
const deviceType = parseDeviceType(firstDevice?.user_agent || "");
|
const deviceType = parseDeviceType(firstDevice?.user_agent || "");
|
||||||
const display = isDevice ? deviceNo || identifier : identifier;
|
const display = isDevice ? deviceNo || identifier : identifier;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{/* <Badge
|
{/* <Badge
|
||||||
className="mr-1 uppercase"
|
className="mr-1 uppercase"
|
||||||
title={method?.verified ? t("verified", "Verified") : ""}
|
title={method?.verified ? t("verified", "Verified") : ""}
|
||||||
>
|
>
|
||||||
{method?.auth_type}
|
{method?.auth_type}
|
||||||
</Badge>*/}
|
</Badge>*/}
|
||||||
{deviceType && (
|
{deviceType && (
|
||||||
<Badge className="mr-1" variant="secondary">
|
<Badge className="mr-1" variant="secondary">
|
||||||
{deviceType}
|
{deviceType}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span title={isDevice ? display : undefined}>{display}</span>
|
<span title={isDevice ? display : undefined}>{display}</span>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<div className={"flex items-center"}>
|
<div className={'flex items-center'}>
|
||||||
{row.original?.remark ? `(${row.original.remark})` : ""}
|
{row.original?.remark ? `(${row.original.remark})` : ''}
|
||||||
<FilePenLine className={"ml-2 text-primary"} size={14} />
|
<FilePenLine size={14} className={'text-primary ml-2'} />
|
||||||
</div>
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className={"w-64"}>
|
<PopoverContent className={'w-64'}>
|
||||||
<RemarkForm
|
<RemarkForm
|
||||||
initialRemark={row.original.remark}
|
initialRemark={row.original.remark}
|
||||||
onSave={async (remark) => {
|
CloseComponent={PopoverClose}
|
||||||
const { data } = await getUserDetail({
|
onSave={async (remark) => {
|
||||||
id: row.original.id,
|
const {
|
||||||
});
|
auth_methods: _auth_methods,
|
||||||
const user = data.data;
|
user_devices: _user_devices,
|
||||||
|
enable_balance_notify: _enable_balance_notify,
|
||||||
if (!user) {
|
enable_login_notify: _enable_login_notify,
|
||||||
throw new Error("User detail not found");
|
enable_subscribe_notify: _enable_subscribe_notify,
|
||||||
}
|
enable_trade_notify: _enable_trade_notify,
|
||||||
|
updated_at: _updated_at,
|
||||||
await updateUserBasicInfo(
|
created_at: _created_at,
|
||||||
buildUserBasicInfoPayload(user, remark)
|
id,
|
||||||
);
|
...rest
|
||||||
toast.success(t("updateSuccess"));
|
} = row.original;
|
||||||
ref.current?.refresh();
|
await updateUserBasicInfo({
|
||||||
}}
|
user_id: id,
|
||||||
|
...rest,
|
||||||
|
remark,
|
||||||
|
} as unknown as API.UpdateUserBasiceInfoRequest);
|
||||||
|
toast.success(t('updateSuccess'));
|
||||||
|
ref.current?.refresh();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -319,7 +355,7 @@ export default function User() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
/* {
|
/* {
|
||||||
id: "balance",
|
id: "balance",
|
||||||
accessorKey: "balance",
|
accessorKey: "balance",
|
||||||
header: t("balance", "Balance"),
|
header: t("balance", "Balance"),
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment jsdom
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
cleanup,
|
|
||||||
fireEvent,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
waitFor,
|
|
||||||
} from "@testing-library/react";
|
|
||||||
import { Popover } from "@workspace/ui/components/popover";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { RemarkForm } from "./remark-form";
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderRemarkForm(props: Parameters<typeof RemarkForm>[0]) {
|
|
||||||
return render(
|
|
||||||
<Popover open>
|
|
||||||
<RemarkForm {...props} />
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("RemarkForm", () => {
|
|
||||||
it("submits an intentionally cleared remark", async () => {
|
|
||||||
const onSave = vi.fn<Parameters<typeof RemarkForm>[0]["onSave"]>();
|
|
||||||
|
|
||||||
renderRemarkForm({ initialRemark: "old remark", onSave });
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("在此输入备注..."), {
|
|
||||||
target: { value: "" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "保存" }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(""));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows loading and keeps the input disabled while saving", async () => {
|
|
||||||
let resolveSave: (() => void) | undefined;
|
|
||||||
const onSave = vi.fn(
|
|
||||||
() =>
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
resolveSave = resolve;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
renderRemarkForm({ initialRemark: "old remark", onSave });
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "保存" }));
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "保存中..." }).hasAttribute("disabled")
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
screen.getByPlaceholderText("在此输入备注...").hasAttribute("disabled")
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
resolveSave?.();
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "保存" }).hasAttribute("disabled")
|
|
||||||
).toBe(false)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows an error state and preserves input after save failure", async () => {
|
|
||||||
const onSave = vi.fn<Parameters<typeof RemarkForm>[0]["onSave"]>();
|
|
||||||
onSave.mockRejectedValue(new Error("failed"));
|
|
||||||
|
|
||||||
renderRemarkForm({ initialRemark: "old remark", onSave });
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("在此输入备注..."), {
|
|
||||||
target: { value: "kept remark" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "保存" }));
|
|
||||||
|
|
||||||
expect((await screen.findByRole("alert")).textContent).toBe(
|
|
||||||
"备注保存失败,请重试"
|
|
||||||
);
|
|
||||||
expect(screen.getByDisplayValue("kept remark")).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Button } from "@workspace/ui/components/button";
|
|
||||||
import { Input } from "@workspace/ui/components/input";
|
|
||||||
import { PopoverClose } from "@workspace/ui/components/popover";
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
|
|
||||||
interface RemarkFormProps {
|
|
||||||
initialRemark?: string | null;
|
|
||||||
onSave: (remark: string) => Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RemarkForm({ onSave, initialRemark }: RemarkFormProps) {
|
|
||||||
const [remark, setRemark] = useState<string>(initialRemark ?? "");
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const closeRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setRemark(event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onSave(remark);
|
|
||||||
closeRef.current?.click();
|
|
||||||
} catch {
|
|
||||||
setError("备注保存失败,请重试");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSave}>
|
|
||||||
<div className="mb-2 font-semibold text-sm">备注</div>
|
|
||||||
<Input
|
|
||||||
aria-invalid={Boolean(error)}
|
|
||||||
className="w-full"
|
|
||||||
disabled={saving}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
placeholder="在此输入备注..."
|
|
||||||
type="text"
|
|
||||||
value={remark}
|
|
||||||
/>
|
|
||||||
{error ? (
|
|
||||||
<p className="mt-2 text-destructive text-sm" role="alert">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
className="mt-2"
|
|
||||||
disabled={saving}
|
|
||||||
size="sm"
|
|
||||||
type="submit"
|
|
||||||
variant="default"
|
|
||||||
>
|
|
||||||
{saving ? "保存中..." : "保存"}
|
|
||||||
</Button>
|
|
||||||
<PopoverClose asChild>
|
|
||||||
<button className="sr-only" ref={closeRef} type="button">
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
</PopoverClose>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { buildUserBasicInfoPayload } from "./user-basic-info-payload";
|
|
||||||
|
|
||||||
const user = {
|
|
||||||
id: 37,
|
|
||||||
avatar: "https://example.com/avatar.png",
|
|
||||||
balance: 1200,
|
|
||||||
commission: 300,
|
|
||||||
referral_percentage: 10,
|
|
||||||
only_first_purchase: true,
|
|
||||||
gift_amount: 500,
|
|
||||||
telegram: 123_456,
|
|
||||||
refer_code: "INVITE37",
|
|
||||||
referer_id: 7,
|
|
||||||
enable: true,
|
|
||||||
is_admin: false,
|
|
||||||
enable_balance_notify: true,
|
|
||||||
enable_login_notify: true,
|
|
||||||
enable_subscribe_notify: false,
|
|
||||||
enable_trade_notify: false,
|
|
||||||
user_group_id: 2,
|
|
||||||
group_locked: false,
|
|
||||||
auth_methods: [],
|
|
||||||
user_devices: [],
|
|
||||||
remark: "old remark",
|
|
||||||
rules: [],
|
|
||||||
created_at: 1_700_000_000,
|
|
||||||
updated_at: 1_700_000_100,
|
|
||||||
} satisfies API.User;
|
|
||||||
|
|
||||||
describe("buildUserBasicInfoPayload", () => {
|
|
||||||
it("keeps the full basic user fields when updating a remark", () => {
|
|
||||||
expect(buildUserBasicInfoPayload(user, "new remark")).toEqual({
|
|
||||||
user_id: 37,
|
|
||||||
avatar: "https://example.com/avatar.png",
|
|
||||||
balance: 1200,
|
|
||||||
commission: 300,
|
|
||||||
referral_percentage: 10,
|
|
||||||
only_first_purchase: true,
|
|
||||||
gift_amount: 500,
|
|
||||||
telegram: 123_456,
|
|
||||||
refer_code: "INVITE37",
|
|
||||||
referer_id: 7,
|
|
||||||
enable: true,
|
|
||||||
is_admin: false,
|
|
||||||
remark: "new remark",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves an intentionally cleared remark", () => {
|
|
||||||
expect(buildUserBasicInfoPayload(user, "").remark).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import type { UpdateUserBasicInfoBody } from "@workspace/ui/services/admin/user";
|
|
||||||
|
|
||||||
export type UserBasicInfoPayload = Omit<UpdateUserBasicInfoBody, "remark"> & {
|
|
||||||
remark: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildUserBasicInfoPayload(
|
|
||||||
user: API.User,
|
|
||||||
remark: string
|
|
||||||
): UserBasicInfoPayload {
|
|
||||||
return {
|
|
||||||
user_id: user.id,
|
|
||||||
avatar: user.avatar,
|
|
||||||
balance: user.balance,
|
|
||||||
commission: user.commission,
|
|
||||||
referral_percentage: user.referral_percentage,
|
|
||||||
only_first_purchase: user.only_first_purchase,
|
|
||||||
gift_amount: user.gift_amount,
|
|
||||||
telegram: user.telegram,
|
|
||||||
refer_code: user.refer_code,
|
|
||||||
referer_id: user.referer_id,
|
|
||||||
enable: user.enable,
|
|
||||||
is_admin: user.is_admin ?? false,
|
|
||||||
remark,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
|
|
||||||
[context.admin.environment]
|
[context.admin.environment]
|
||||||
NODE_VERSION = "20"
|
NODE_VERSION = "20"
|
||||||
VITE_API_BASE_URL = "https://tapi.hifast.biz"
|
|
||||||
|
|
||||||
# User 用户前台应用
|
# User 用户前台应用
|
||||||
[context.user]
|
[context.user]
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
export type UpdateUserBasicInfoBody = Omit<
|
|
||||||
API.UpdateUserBasiceInfoRequest,
|
|
||||||
"password"
|
|
||||||
> & {
|
|
||||||
password?: string;
|
|
||||||
remark?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Create user POST /v1/admin/user/ */
|
/** Create user POST /v1/admin/user/ */
|
||||||
export async function createUser(
|
export async function createUser(
|
||||||
body: API.CreateUserRequest,
|
body: API.CreateUserRequest,
|
||||||
@@ -112,7 +104,7 @@ export async function deleteUserAuthMethod(
|
|||||||
|
|
||||||
/** Update user basic info PUT /v1/admin/user/basic */
|
/** Update user basic info PUT /v1/admin/user/basic */
|
||||||
export async function updateUserBasicInfo(
|
export async function updateUserBasicInfo(
|
||||||
body: UpdateUserBasicInfoBody,
|
body: API.UpdateUserBasiceInfoRequest,
|
||||||
options?: { [key: string]: any }
|
options?: { [key: string]: any }
|
||||||
) {
|
) {
|
||||||
return request<API.Response & { data?: any }>(
|
return request<API.Response & { data?: any }>(
|
||||||
|
|||||||
Reference in New Issue
Block a user