mirror of
https://gitee.com/nocobase/nocobase.git
synced 2024-11-29 18:58:26 +08:00
feat(basic-auth): allows to add more fields in sign up form (#5717)
* feat(basic-auth): allows to add more fields in sign up form * feat: backend * feat: add reactions * fix: bug
This commit is contained in:
parent
ede87a18da
commit
ac80dd8a3c
@ -32,6 +32,7 @@ export type AuthManagerOptions = {
|
||||
type AuthConfig = {
|
||||
auth: AuthExtend<Auth>; // The authentication class.
|
||||
title?: string; // The display name of the authentication type.
|
||||
getPublicOptions?: (options: Record<string, any>) => Record<string, any>; // Get the public options.
|
||||
};
|
||||
|
||||
export class AuthManager {
|
||||
|
@ -7,18 +7,151 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { SchemaComponent } from '@nocobase/client';
|
||||
import React from 'react';
|
||||
import { SchemaComponent, useCollectionManager, useRecord } from '@nocobase/client';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { lang, useAuthTranslation } from '../locale';
|
||||
import { FormTab, ArrayTable } from '@formily/antd-v5';
|
||||
import { Alert } from 'antd';
|
||||
import { uid } from '@formily/shared';
|
||||
|
||||
const SignupFormSettings = () => {
|
||||
const record = useRecord();
|
||||
const cm = useCollectionManager();
|
||||
const userCollection = cm.getCollection('users');
|
||||
const fields = userCollection.fields.filter(
|
||||
(field) => !field.hidden && !field.target && field.interface && !field.uiSchema?.['x-read-pretty'],
|
||||
);
|
||||
const enumArr = fields.map((field) => ({ value: field.name, label: field.uiSchema?.title }));
|
||||
const value = useMemo(() => {
|
||||
const fieldValue = record.options?.public?.signupForm || [];
|
||||
const newValue = fieldValue.filter((item: any) => fields.find((field) => field.name === item.field));
|
||||
for (const field of fields) {
|
||||
const exist = newValue.find((item: any) => item.field === field.name);
|
||||
if (!exist) {
|
||||
newValue.push({
|
||||
field: field.name,
|
||||
show: field.name === 'username' || field.name === 'email',
|
||||
required: field.name === 'username',
|
||||
});
|
||||
}
|
||||
}
|
||||
return newValue;
|
||||
}, [fields, record]);
|
||||
useEffect(() => {
|
||||
record.options = {
|
||||
...record.options,
|
||||
public: {
|
||||
...record.options?.public,
|
||||
signupForm: value,
|
||||
},
|
||||
};
|
||||
}, [record, value]);
|
||||
|
||||
return (
|
||||
<SchemaComponent
|
||||
components={{ ArrayTable }}
|
||||
schema={{
|
||||
type: 'void',
|
||||
properties: {
|
||||
signupForm: {
|
||||
title: '{{t("Sign up form")}}',
|
||||
type: 'array',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'ArrayTable',
|
||||
'x-component-props': {
|
||||
bordered: false,
|
||||
},
|
||||
'x-validator': `{{ (value) => {
|
||||
const check = value?.some((item) => ['username', 'email'].includes(item.field) && item.show && item.required);
|
||||
if (!check) {
|
||||
return t('At least one of the username or email fields is required');
|
||||
}
|
||||
} }}`,
|
||||
default: value,
|
||||
items: {
|
||||
type: 'object',
|
||||
'x-decorator': 'ArrayItems.Item',
|
||||
properties: {
|
||||
column0: {
|
||||
type: 'void',
|
||||
'x-component': 'ArrayTable.Column',
|
||||
'x-component-props': { width: 20, align: 'center' },
|
||||
properties: {
|
||||
sort: {
|
||||
type: 'void',
|
||||
'x-component': 'ArrayTable.SortHandle',
|
||||
},
|
||||
},
|
||||
},
|
||||
column1: {
|
||||
type: 'void',
|
||||
'x-component': 'ArrayTable.Column',
|
||||
'x-component-props': { width: 100, title: lang('Field') },
|
||||
properties: {
|
||||
field: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Select',
|
||||
enum: enumArr,
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
column2: {
|
||||
type: 'void',
|
||||
'x-component': 'ArrayTable.Column',
|
||||
'x-component-props': { width: 80, title: lang('Show') },
|
||||
properties: {
|
||||
show: {
|
||||
type: 'boolean',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Checkbox',
|
||||
'x-reactions': {
|
||||
dependencies: ['.required'],
|
||||
fulfill: {
|
||||
state: {
|
||||
value: '{{ $deps[0] || $self.value }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
column3: {
|
||||
type: 'void',
|
||||
'x-component': 'ArrayTable.Column',
|
||||
'x-component-props': { width: 80, title: lang('Required') },
|
||||
properties: {
|
||||
required: {
|
||||
type: 'boolean',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Checkbox',
|
||||
'x-reactions': {
|
||||
dependencies: ['.show'],
|
||||
fulfill: {
|
||||
state: {
|
||||
value: '{{ !$deps[0] ? false : $self.value }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Options = () => {
|
||||
const { t } = useAuthTranslation();
|
||||
return (
|
||||
<SchemaComponent
|
||||
scope={{ t }}
|
||||
components={{ Alert, FormTab, ArrayTable }}
|
||||
components={{ Alert, SignupFormSettings, FormTab }}
|
||||
schema={{
|
||||
type: 'object',
|
||||
properties: {
|
||||
@ -52,96 +185,9 @@ export const Options = () => {
|
||||
'x-component': 'Checkbox',
|
||||
default: true,
|
||||
},
|
||||
signupForm: {
|
||||
title: '{{t("Sign up form")}}',
|
||||
type: 'array',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'ArrayTable',
|
||||
'x-component-props': {
|
||||
bordered: false,
|
||||
},
|
||||
'x-validator': `{{ (value) => {
|
||||
const field = value?.filter((item) => item.show && item.required);
|
||||
if (!field?.length) {
|
||||
return t('At least one field is required');
|
||||
}
|
||||
} }}`,
|
||||
default: [
|
||||
{
|
||||
field: 'username',
|
||||
show: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
show: false,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
items: {
|
||||
type: 'object',
|
||||
'x-decorator': 'ArrayItems.Item',
|
||||
properties: {
|
||||
column0: {
|
||||
type: 'void',
|
||||
'x-component': 'ArrayTable.Column',
|
||||
'x-component-props': { width: 20, align: 'center' },
|
||||
properties: {
|
||||
sort: {
|
||||
type: 'void',
|
||||
'x-component': 'ArrayTable.SortHandle',
|
||||
},
|
||||
},
|
||||
},
|
||||
column1: {
|
||||
type: 'void',
|
||||
'x-component': 'ArrayTable.Column',
|
||||
'x-component-props': { width: 100, title: lang('Field') },
|
||||
properties: {
|
||||
field: {
|
||||
type: 'string',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Select',
|
||||
enum: [
|
||||
{
|
||||
label: lang('Username'),
|
||||
value: 'username',
|
||||
},
|
||||
{
|
||||
label: lang('Email'),
|
||||
value: 'email',
|
||||
},
|
||||
],
|
||||
'x-read-pretty': true,
|
||||
},
|
||||
},
|
||||
},
|
||||
column2: {
|
||||
type: 'void',
|
||||
'x-component': 'ArrayTable.Column',
|
||||
'x-component-props': { width: 80, title: lang('Show') },
|
||||
properties: {
|
||||
show: {
|
||||
type: 'boolean',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Checkbox',
|
||||
},
|
||||
},
|
||||
},
|
||||
column3: {
|
||||
type: 'void',
|
||||
'x-component': 'ArrayTable.Column',
|
||||
'x-component-props': { width: 80, title: lang('Required') },
|
||||
properties: {
|
||||
required: {
|
||||
type: 'boolean',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Checkbox',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
[uid()]: {
|
||||
type: 'void',
|
||||
'x-component': 'SignupFormSettings',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -26,23 +26,6 @@ export interface UseSignupProps {
|
||||
};
|
||||
}
|
||||
|
||||
const schemas = {
|
||||
username: {
|
||||
type: 'string',
|
||||
'x-component': 'Input',
|
||||
'x-validator': { username: true },
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component-props': { placeholder: '{{t("Username")}}', style: {} },
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
'x-component': 'Input',
|
||||
'x-validator': 'email',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component-props': { placeholder: '{{t("Email")}}', style: {} },
|
||||
},
|
||||
};
|
||||
|
||||
export const useSignUp = (props?: UseSignupProps) => {
|
||||
const navigate = useNavigate();
|
||||
const form = useForm();
|
||||
@ -69,9 +52,10 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({
|
||||
password: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
title: '{{t("Password")}}',
|
||||
'x-component': 'Password',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component-props': { placeholder: '{{t("Password")}}', checkStrength: true, style: {} },
|
||||
'x-component-props': { checkStrength: true, style: {} },
|
||||
'x-reactions': [
|
||||
{
|
||||
dependencies: ['.confirm_password'],
|
||||
@ -88,7 +72,8 @@ const getSignupPageSchema = (fieldSchemas: any): ISchema => ({
|
||||
required: true,
|
||||
'x-component': 'Password',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component-props': { placeholder: '{{t("Confirm password")}}', style: {} },
|
||||
title: '{{t("Confirm password")}}',
|
||||
'x-component-props': { style: {} },
|
||||
'x-reactions': [
|
||||
{
|
||||
dependencies: ['.password'],
|
||||
@ -147,15 +132,13 @@ export const SignUpForm = ({ authenticatorName: name }: { authenticatorName: str
|
||||
const fieldSchemas = useMemo(() => {
|
||||
return signupForm
|
||||
.filter((field: { show: boolean }) => field.show)
|
||||
.reduce((prev: any, item: { field: string; required: boolean }) => {
|
||||
const field = item.field;
|
||||
if (schemas[field]) {
|
||||
prev[field] = schemas[field];
|
||||
if (item.required) {
|
||||
prev[field].required = true;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
.reduce((prev: any, item: { field: string; required: boolean; uiSchema: any }) => {
|
||||
prev[item.field] = {
|
||||
...item.uiSchema,
|
||||
required: item.required,
|
||||
'x-decorator': 'FormItem',
|
||||
};
|
||||
return prev;
|
||||
}, {});
|
||||
}, [signupForm]);
|
||||
if (!options?.allowSignUp) {
|
||||
|
@ -28,5 +28,5 @@
|
||||
"Show": "Show",
|
||||
"Sign up settings": "Sign up settings",
|
||||
"Sign up form": "Sign up form",
|
||||
"At least one field is required": "At least one field is required"
|
||||
"At least one of the username or email fields is required": "At least one of the username or email fields is required"
|
||||
}
|
||||
|
@ -28,5 +28,5 @@
|
||||
"Show": "显示",
|
||||
"Sign up settings": "注册设置",
|
||||
"Sign up form": "注册表单",
|
||||
"At least one field is required": "至少需要设置一个必填字段"
|
||||
"At least one of the username or email fields is required": "至少需要设置用户名或邮箱中的一个字段为必填字段"
|
||||
}
|
||||
|
@ -46,18 +46,30 @@ describe('actions', () => {
|
||||
});
|
||||
|
||||
it('should return enabled authenticators with public options', async () => {
|
||||
app.authManager.registerTypes('testType1', {
|
||||
auth: {} as any,
|
||||
getPublicOptions: (options) => {
|
||||
return {
|
||||
text: 'custom public options',
|
||||
};
|
||||
},
|
||||
});
|
||||
await repo.destroy({
|
||||
truncate: true,
|
||||
});
|
||||
await repo.createMany({
|
||||
records: [
|
||||
{ name: 'test', authType: 'testType', enabled: true, options: { public: { test: 1 }, private: { test: 2 } } },
|
||||
{ name: 'test1', authType: 'testType1', enabled: true },
|
||||
{ name: 'test2', authType: 'testType' },
|
||||
],
|
||||
});
|
||||
const res = await agent.resource('authenticators').publicList();
|
||||
expect(res.body.data.length).toBe(1);
|
||||
expect(res.body.data.length).toBe(2);
|
||||
expect(res.body.data[0].name).toBe('test');
|
||||
expect(res.body.data[0].options).toMatchObject({ test: 1 });
|
||||
expect(res.body.data[1].name).toBe('test1');
|
||||
expect(res.body.data[1].options).toMatchObject({ text: 'custom public options' });
|
||||
});
|
||||
|
||||
it('should keep at least one authenticator', async () => {
|
||||
@ -260,16 +272,37 @@ describe('actions', () => {
|
||||
});
|
||||
|
||||
it('should check username when signing up', async () => {
|
||||
const res1 = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
|
||||
username: '',
|
||||
});
|
||||
expect(res1.statusCode).toEqual(400);
|
||||
expect(res1.error.text).toBe('Please enter a valid username');
|
||||
const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
|
||||
username: '@@',
|
||||
username: '',
|
||||
});
|
||||
expect(res.statusCode).toEqual(400);
|
||||
expect(res.error.text).toBe('Please enter a valid username');
|
||||
const res1 = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
|
||||
username: '@@',
|
||||
});
|
||||
expect(res1.statusCode).toEqual(400);
|
||||
expect(res1.error.text).toBe('Please enter a valid username');
|
||||
|
||||
const repo = db.getRepository('authenticators');
|
||||
await repo.update({
|
||||
filter: {
|
||||
name: 'basic',
|
||||
},
|
||||
values: {
|
||||
options: {
|
||||
public: {
|
||||
allowSignUp: true,
|
||||
signupForm: [{ field: 'nickname', show: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res2 = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
|
||||
nickname: 'test',
|
||||
});
|
||||
expect(res2.statusCode).toEqual(400);
|
||||
expect(res2.error.text).toBe('Please enter a valid username');
|
||||
});
|
||||
|
||||
it('should check email when signing up', async () => {
|
||||
@ -305,6 +338,31 @@ describe('actions', () => {
|
||||
expect(res3.statusCode).toEqual(200);
|
||||
});
|
||||
|
||||
it('should check a required field when signing up', async () => {
|
||||
const repo = db.getRepository('authenticators');
|
||||
await repo.update({
|
||||
filter: {
|
||||
name: 'basic',
|
||||
},
|
||||
values: {
|
||||
options: {
|
||||
public: {
|
||||
allowSignUp: true,
|
||||
signupForm: [
|
||||
{ field: 'username', show: true, required: true },
|
||||
{ field: 'nickname', show: true, required: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const res1 = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
|
||||
username: 'test',
|
||||
});
|
||||
expect(res1.statusCode).toEqual(400);
|
||||
expect(res1.error.text).toBe('Please enter nickname');
|
||||
});
|
||||
|
||||
it('should check password when signing up', async () => {
|
||||
const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
|
||||
username: 'new',
|
||||
@ -313,6 +371,41 @@ describe('actions', () => {
|
||||
expect(res.error.text).toBe('Please enter a password');
|
||||
});
|
||||
|
||||
it('should write correct user data when signing up', async () => {
|
||||
const repo = db.getRepository('authenticators');
|
||||
await repo.update({
|
||||
filter: {
|
||||
name: 'basic',
|
||||
},
|
||||
values: {
|
||||
options: {
|
||||
public: {
|
||||
allowSignUp: true,
|
||||
signupForm: [
|
||||
{ field: 'username', show: true, required: true },
|
||||
{ field: 'nickname', show: true, required: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({
|
||||
username: 'test',
|
||||
nickname: 'Test',
|
||||
phone: '12345678901',
|
||||
password: '123456',
|
||||
confirm_password: '123456',
|
||||
});
|
||||
expect(res.statusCode).toEqual(200);
|
||||
const user = await db.getRepository('users').findOne({
|
||||
filter: {
|
||||
username: 'test',
|
||||
},
|
||||
});
|
||||
expect(user.nickname).toBe('Test');
|
||||
expect(user.phone).toBeNull();
|
||||
});
|
||||
|
||||
it('should sign user out when changing password', async () => {
|
||||
const userRepo = db.getRepository('users');
|
||||
const user = await userRepo.create({
|
||||
|
@ -49,7 +49,7 @@ export default {
|
||||
authType: authenticator.authType,
|
||||
authTypeTitle: authType?.title || '',
|
||||
title: authenticator.title,
|
||||
options: authenticator.options?.public || {},
|
||||
options: authType?.getPublicOptions?.(authenticator.options) || authenticator.options?.public || {},
|
||||
};
|
||||
});
|
||||
await next();
|
||||
|
@ -11,6 +11,7 @@ import { AuthConfig, BaseAuth } from '@nocobase/auth';
|
||||
import { PasswordField } from '@nocobase/database';
|
||||
import crypto from 'crypto';
|
||||
import { namespace } from '../preset';
|
||||
import _ from 'lodash';
|
||||
|
||||
export class BasicAuth extends BaseAuth {
|
||||
constructor(config: AuthConfig) {
|
||||
@ -50,20 +51,41 @@ export class BasicAuth extends BaseAuth {
|
||||
return user;
|
||||
}
|
||||
|
||||
private verfiySignupParams(values: any) {
|
||||
private getSignupFormSettings() {
|
||||
const options = this.authenticator.options?.public || {};
|
||||
let { signupForm } = options;
|
||||
if (!(signupForm?.length && signupForm.some((item: any) => item.show && item.required))) {
|
||||
signupForm = [{ field: 'username', show: true, required: true }];
|
||||
let { signupForm = [] } = options;
|
||||
signupForm = signupForm.filter((item: { show: boolean }) => item.show);
|
||||
if (
|
||||
!(
|
||||
signupForm.length &&
|
||||
signupForm.some(
|
||||
(item: { field: string; show: boolean; required: boolean }) =>
|
||||
['username', 'email'].includes(item.field) && item.show && item.required,
|
||||
)
|
||||
)
|
||||
) {
|
||||
// At least one of the username or email fields is required
|
||||
signupForm.push({ field: 'username', show: true, required: true });
|
||||
}
|
||||
return signupForm;
|
||||
}
|
||||
|
||||
private verfiySignupParams(
|
||||
signupFormSettings: {
|
||||
field: string;
|
||||
show: boolean;
|
||||
required: boolean;
|
||||
}[],
|
||||
values: any,
|
||||
) {
|
||||
const { username, email } = values;
|
||||
const usernameSetting = signupForm.find((item: any) => item.field === 'username');
|
||||
const usernameSetting = signupFormSettings.find((item: any) => item.field === 'username');
|
||||
if (usernameSetting && usernameSetting.show) {
|
||||
if ((username && !this.validateUsername(username)) || (usernameSetting.required && !username)) {
|
||||
throw new Error('Please enter a valid username');
|
||||
}
|
||||
}
|
||||
const emailSetting = signupForm.find((item: any) => item.field === 'email');
|
||||
const emailSetting = signupFormSettings.find((item: any) => item.field === 'email');
|
||||
if (emailSetting && emailSetting.show) {
|
||||
if (email && !/^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(email)) {
|
||||
throw new Error('Please enter a valid email address');
|
||||
@ -72,6 +94,13 @@ export class BasicAuth extends BaseAuth {
|
||||
throw new Error('Please enter a valid email address');
|
||||
}
|
||||
}
|
||||
|
||||
const requiredFields = signupFormSettings.filter((item: any) => item.show && item.required);
|
||||
requiredFields.forEach((item: { field: string }) => {
|
||||
if (!values[item.field]) {
|
||||
throw new Error(`Please enter ${item.field}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async signUp() {
|
||||
@ -82,9 +111,10 @@ export class BasicAuth extends BaseAuth {
|
||||
}
|
||||
const User = ctx.db.getRepository('users');
|
||||
const { values } = ctx.action.params;
|
||||
const { username, email, password, confirm_password } = values;
|
||||
const { password, confirm_password } = values;
|
||||
const signupFormSettings = this.getSignupFormSettings();
|
||||
try {
|
||||
this.verfiySignupParams(values);
|
||||
this.verfiySignupParams(signupFormSettings, values);
|
||||
} catch (error) {
|
||||
ctx.throw(400, this.ctx.t(error.message, { ns: namespace }));
|
||||
}
|
||||
@ -94,7 +124,9 @@ export class BasicAuth extends BaseAuth {
|
||||
if (password !== confirm_password) {
|
||||
ctx.throw(400, ctx.t('The password is inconsistent, please re-enter', { ns: namespace }));
|
||||
}
|
||||
const user = await User.create({ values: { username, email, password } });
|
||||
const fields = signupFormSettings.map((item: { field: string }) => item.field);
|
||||
const userValues = _.pick(values, fields);
|
||||
const user = await User.create({ values: { ...userValues, password } });
|
||||
return user;
|
||||
}
|
||||
|
||||
|
@ -50,6 +50,26 @@ export class PluginAuthServer extends Plugin {
|
||||
this.app.authManager.registerTypes(presetAuthType, {
|
||||
auth: BasicAuth,
|
||||
title: tval('Password', { ns: namespace }),
|
||||
getPublicOptions: (options) => {
|
||||
const usersCollection = this.db.getCollection('users');
|
||||
const signupForm =
|
||||
options?.public?.signupForm
|
||||
?.filter((field: { show: boolean }) => field.show)
|
||||
.map((item: { field: string; required: boolean }) => {
|
||||
const field = usersCollection.getField(item.field);
|
||||
return {
|
||||
...item,
|
||||
uiSchema: {
|
||||
...field.options?.uiSchema,
|
||||
required: item.required,
|
||||
},
|
||||
};
|
||||
}) || [];
|
||||
return {
|
||||
...options?.public,
|
||||
signupForm,
|
||||
};
|
||||
},
|
||||
});
|
||||
// Register actions
|
||||
Object.entries(authActions).forEach(
|
||||
|
Loading…
Reference in New Issue
Block a user