feat(auth): registration mode on ConnectPage (PUBLIC_ENABLE_REGISTRATION)
Add a login/register toggle to ConnectPage backed by a new useRegisterMutation (register -> /auth/me, mirroring login). The toggle is shown only when REGISTRATION_ENABLED, resolved with the same precedence as the API base URL: runtime window.__APP_CONFIG__ > PUBLIC_ENABLE_REGISTRATION env > default true. The prod runtime-config script injects the runtime flag. The backend's ALLOW_REGISTRATION stays the real authority; this only gates the UI. EN/RU strings added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +1,6 @@
|
|||||||
# Default backend URL (overridable at runtime in the UI)
|
# Default backend URL (overridable at runtime in the UI)
|
||||||
PUBLIC_API_BASE_URL=http://localhost:8080/api/v1
|
PUBLIC_API_BASE_URL=http://localhost:8080/api/v1
|
||||||
|
|
||||||
|
# Show the public sign-up UI on the connect screen. Set to false to hide it.
|
||||||
|
# The backend's ALLOW_REGISTRATION is the real authority; this only gates the UI.
|
||||||
|
PUBLIC_ENABLE_REGISTRATION=true
|
||||||
|
|||||||
@@ -2,15 +2,25 @@
|
|||||||
# Write the SPA's runtime operator config at container start.
|
# Write the SPA's runtime operator config at container start.
|
||||||
#
|
#
|
||||||
# The nginx base image runs every /docker-entrypoint.d/*.sh before launching
|
# The nginx base image runs every /docker-entrypoint.d/*.sh before launching
|
||||||
# nginx, so this overwrites the build-time public/config.js stub with the value
|
# nginx, so this overwrites the build-time public/config.js stub with the
|
||||||
# of $PUBLIC_API_BASE_URL. That lets one prebuilt image target any backend
|
# operator's runtime config ($PUBLIC_API_BASE_URL, $PUBLIC_ENABLE_REGISTRATION).
|
||||||
# origin without rebuilding. Resolution + precedence live in src/config/env.ts.
|
# That lets one prebuilt image target any backend origin and toggle sign-up
|
||||||
|
# without rebuilding. Resolution + precedence live in src/config/env.ts.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
: "${PUBLIC_API_BASE_URL:=/api/v1}"
|
: "${PUBLIC_API_BASE_URL:=/api/v1}"
|
||||||
|
: "${PUBLIC_ENABLE_REGISTRATION:=true}"
|
||||||
ROOT="${NGINX_HTML_ROOT:-/usr/share/nginx/html}"
|
ROOT="${NGINX_HTML_ROOT:-/usr/share/nginx/html}"
|
||||||
|
|
||||||
printf 'window.__APP_CONFIG__={"apiBaseUrl":"%s"};\n' "$PUBLIC_API_BASE_URL" \
|
# Anything but "false"/"0" enables the sign-up UI (mirrors parseFlag in env.ts).
|
||||||
|
if [ "$PUBLIC_ENABLE_REGISTRATION" = "false" ] || [ "$PUBLIC_ENABLE_REGISTRATION" = "0" ]; then
|
||||||
|
ENABLE_REGISTRATION=false
|
||||||
|
else
|
||||||
|
ENABLE_REGISTRATION=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'window.__APP_CONFIG__={"apiBaseUrl":"%s","enableRegistration":%s};\n' \
|
||||||
|
"$PUBLIC_API_BASE_URL" "$ENABLE_REGISTRATION" \
|
||||||
>"$ROOT/config.js"
|
>"$ROOT/config.js"
|
||||||
|
|
||||||
echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL to $ROOT/config.js"
|
echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL enableRegistration=$ENABLE_REGISTRATION to $ROOT/config.js"
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
import { toUser, type RawUser } from '../mappers';
|
import { toUser, type RawUser } from '../mappers';
|
||||||
import type { AuthTokens, LoginRequest, LoginResponse, User } from '../types';
|
import type {
|
||||||
|
AuthTokens,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
RegisterRequest,
|
||||||
|
User,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth seam over the backend's wire format: tokens-only login + a separate
|
* Auth seam over the backend's wire format: tokens-only login + a separate
|
||||||
@@ -48,6 +54,29 @@ export const authApi = api.injectEndpoints({
|
|||||||
return { data: { user, tokens } };
|
return { data: { user, tokens } };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
// Sign-up mirrors login: POST /auth/register returns a token pair (the
|
||||||
|
// backend logs the new account straight in), then GET /auth/me resolves the
|
||||||
|
// user — so the UI gets the same unified { user, tokens } as login.
|
||||||
|
register: build.mutation<LoginResponse, RegisterRequest>({
|
||||||
|
async queryFn(body, _api, _extra, baseQuery) {
|
||||||
|
const tokenRes = await baseQuery({
|
||||||
|
url: '/auth/register',
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (tokenRes.error) return { error: tokenRes.error };
|
||||||
|
const tokens = toTokens(tokenRes.data as RawTokenResponse);
|
||||||
|
|
||||||
|
const meRes = await baseQuery({
|
||||||
|
url: '/auth/me',
|
||||||
|
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||||
|
});
|
||||||
|
if (meRes.error) return { error: meRes.error };
|
||||||
|
const user = toUser(meRes.data as RawUser);
|
||||||
|
|
||||||
|
return { data: { user, tokens } };
|
||||||
|
},
|
||||||
|
}),
|
||||||
logout: build.mutation<void, { refreshToken: string }>({
|
logout: build.mutation<void, { refreshToken: string }>({
|
||||||
query: ({ refreshToken }) => ({
|
query: ({ refreshToken }) => ({
|
||||||
url: '/auth/logout',
|
url: '/auth/logout',
|
||||||
@@ -74,6 +103,7 @@ export const authApi = api.injectEndpoints({
|
|||||||
|
|
||||||
export const {
|
export const {
|
||||||
useLoginMutation,
|
useLoginMutation,
|
||||||
|
useRegisterMutation,
|
||||||
useLogoutMutation,
|
useLogoutMutation,
|
||||||
useRefreshTokenMutation,
|
useRefreshTokenMutation,
|
||||||
useMeQuery,
|
useMeQuery,
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ export interface LoginResponse {
|
|||||||
tokens: AuthTokens;
|
tokens: AuthTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -20,3 +20,22 @@ function runtimeApiBaseUrl(): string | undefined {
|
|||||||
|
|
||||||
export const DEFAULT_API_BASE_URL =
|
export const DEFAULT_API_BASE_URL =
|
||||||
runtimeApiBaseUrl() ?? import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
|
runtimeApiBaseUrl() ?? import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the public sign-up UI is shown. Same precedence as the base URL:
|
||||||
|
* runtime operator config (injected into `window.__APP_CONFIG__` at container
|
||||||
|
* start) wins over the build-time `PUBLIC_ENABLE_REGISTRATION` env, which
|
||||||
|
* defaults to enabled. This only gates the *UI*; the backend independently
|
||||||
|
* enforces `ALLOW_REGISTRATION` and is the real authority.
|
||||||
|
*/
|
||||||
|
function parseFlag(value: string | undefined): boolean | undefined {
|
||||||
|
if (value == null || value === '') return undefined;
|
||||||
|
return value !== 'false' && value !== '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REGISTRATION_ENABLED: boolean =
|
||||||
|
(typeof window !== 'undefined'
|
||||||
|
? window.__APP_CONFIG__?.enableRegistration
|
||||||
|
: undefined) ??
|
||||||
|
parseFlag(import.meta.env.PUBLIC_ENABLE_REGISTRATION) ??
|
||||||
|
true;
|
||||||
|
|||||||
Vendored
+2
@@ -1,6 +1,7 @@
|
|||||||
/// <reference types="@rsbuild/core/types" />
|
/// <reference types="@rsbuild/core/types" />
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly PUBLIC_API_BASE_URL?: string;
|
readonly PUBLIC_API_BASE_URL?: string;
|
||||||
|
readonly PUBLIC_ENABLE_REGISTRATION?: string;
|
||||||
}
|
}
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
readonly env: ImportMetaEnv;
|
||||||
@@ -11,5 +12,6 @@ interface ImportMeta {
|
|||||||
interface Window {
|
interface Window {
|
||||||
__APP_CONFIG__?: {
|
__APP_CONFIG__?: {
|
||||||
apiBaseUrl?: string;
|
apiBaseUrl?: string;
|
||||||
|
enableRegistration?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import { Icon } from '../../components/common/Icon';
|
|||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
import { setTokens, setUser } from '../../store/slices/auth';
|
import { setTokens, setUser } from '../../store/slices/auth';
|
||||||
import { setApiBaseUrl } from '../../config/runtime-config';
|
import { setApiBaseUrl } from '../../config/runtime-config';
|
||||||
import { useLoginMutation } from '../../api/endpoints/auth';
|
import {
|
||||||
|
useLoginMutation,
|
||||||
|
useRegisterMutation,
|
||||||
|
} from '../../api/endpoints/auth';
|
||||||
|
import { REGISTRATION_ENABLED } from '../../config/env';
|
||||||
import {
|
import {
|
||||||
listInstances,
|
listInstances,
|
||||||
getActiveInstanceId,
|
getActiveInstanceId,
|
||||||
@@ -15,6 +19,8 @@ import {
|
|||||||
removeInstance,
|
removeInstance,
|
||||||
} from '../../config/instances';
|
} from '../../config/instances';
|
||||||
|
|
||||||
|
type Mode = 'login' | 'register';
|
||||||
|
|
||||||
/** Map an RTKQ login failure to a user-facing i18n key. */
|
/** Map an RTKQ login failure to a user-facing i18n key. */
|
||||||
function resolveLoginError(err: unknown): string {
|
function resolveLoginError(err: unknown): string {
|
||||||
const e = err as FetchBaseQueryError | undefined;
|
const e = err as FetchBaseQueryError | undefined;
|
||||||
@@ -25,6 +31,18 @@ function resolveLoginError(err: unknown): string {
|
|||||||
return 'connect.errors.generic';
|
return 'connect.errors.generic';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Map an RTKQ register failure to a user-facing i18n key. */
|
||||||
|
function resolveRegisterError(err: unknown): string {
|
||||||
|
const e = err as FetchBaseQueryError | undefined;
|
||||||
|
if (e && 'status' in e) {
|
||||||
|
if (e.status === 'FETCH_ERROR') return 'connect.errors.unreachable';
|
||||||
|
if (e.status === 409) return 'connect.errors.usernameTaken';
|
||||||
|
if (e.status === 422) return 'connect.errors.passwordTooShort';
|
||||||
|
if (e.status === 403) return 'connect.errors.registrationDisabled';
|
||||||
|
}
|
||||||
|
return 'connect.errors.registerFailed';
|
||||||
|
}
|
||||||
|
|
||||||
export function ConnectPage() {
|
export function ConnectPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -34,12 +52,20 @@ export function ConnectPage() {
|
|||||||
const instances = listInstances();
|
const instances = listInstances();
|
||||||
const activeId = getActiveInstanceId();
|
const activeId = getActiveInstanceId();
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<Mode>('login');
|
||||||
const [apiUrl, setApiUrl] = useState('https://');
|
const [apiUrl, setApiUrl] = useState('https://');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [login, { isLoading }] = useLoginMutation();
|
const [login, { isLoading: isLoggingIn }] = useLoginMutation();
|
||||||
|
const [register, { isLoading: isRegistering }] = useRegisterMutation();
|
||||||
|
const isLoading = isLoggingIn || isRegistering;
|
||||||
|
|
||||||
|
const switchMode = (next: Mode) => {
|
||||||
|
setMode(next);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const switchTo = (id: string) => {
|
const switchTo = (id: string) => {
|
||||||
setActiveInstanceId(id);
|
setActiveInstanceId(id);
|
||||||
@@ -60,12 +86,20 @@ export function ConnectPage() {
|
|||||||
setApiBaseUrl(apiUrl);
|
setApiBaseUrl(apiUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { user, tokens } = await login({ username, password }).unwrap();
|
const action =
|
||||||
|
mode === 'register'
|
||||||
|
? register({ username, password })
|
||||||
|
: login({ username, password });
|
||||||
|
const { user, tokens } = await action.unwrap();
|
||||||
dispatch(setTokens(tokens));
|
dispatch(setTokens(tokens));
|
||||||
dispatch(setUser(user));
|
dispatch(setUser(user));
|
||||||
void navigate('/');
|
void navigate('/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(resolveLoginError(err));
|
setError(
|
||||||
|
mode === 'register'
|
||||||
|
? resolveRegisterError(err)
|
||||||
|
: resolveLoginError(err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,7 +241,11 @@ export function ConnectPage() {
|
|||||||
padding: '1.5rem',
|
padding: '1.5rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="msk-label">{t('connect.form.title')}</span>
|
<span className="msk-label">
|
||||||
|
{mode === 'register'
|
||||||
|
? t('connect.form.registerTitle')
|
||||||
|
: t('connect.form.title')}
|
||||||
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>{t('connect.form.serverUrl')}</label>
|
<label style={labelStyle}>{t('connect.form.serverUrl')}</label>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -234,9 +272,23 @@ export function ConnectPage() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="password"
|
placeholder="password"
|
||||||
autoComplete="current-password"
|
autoComplete={
|
||||||
|
mode === 'register' ? 'new-password' : 'current-password'
|
||||||
|
}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
{mode === 'register' && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
marginTop: '0.375rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('connect.form.passwordHint')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && <Callout variant="danger">{t(error)}</Callout>}
|
{error && <Callout variant="danger">{t(error)}</Callout>}
|
||||||
<Button
|
<Button
|
||||||
@@ -245,8 +297,50 @@ export function ConnectPage() {
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
style={{ marginTop: '0.5rem' }}
|
style={{ marginTop: '0.5rem' }}
|
||||||
>
|
>
|
||||||
{isLoading ? t('connect.form.submitting') : t('connect.form.submit')}
|
{isLoading
|
||||||
|
? mode === 'register'
|
||||||
|
? t('connect.form.registering')
|
||||||
|
: t('connect.form.submitting')
|
||||||
|
: mode === 'register'
|
||||||
|
? t('connect.form.registerSubmit')
|
||||||
|
: t('connect.form.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{REGISTRATION_ENABLED && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
color: 'var(--color-text-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mode === 'register' ? (
|
||||||
|
<>
|
||||||
|
{t('connect.form.haveAccount')}{' '}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => switchMode('login')}
|
||||||
|
>
|
||||||
|
{t('connect.form.signInLink')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t('connect.form.noAccount')}{' '}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => switchMode('register')}
|
||||||
|
>
|
||||||
|
{t('connect.form.registerLink')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+17
-2
@@ -31,16 +31,29 @@ const en = {
|
|||||||
forgetTitle: 'Forget this instance',
|
forgetTitle: 'Forget this instance',
|
||||||
form: {
|
form: {
|
||||||
title: 'Connect to a backend',
|
title: 'Connect to a backend',
|
||||||
|
registerTitle: 'Create an account',
|
||||||
serverUrl: 'Server URL',
|
serverUrl: 'Server URL',
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
|
passwordHint: 'At least 8 characters.',
|
||||||
submit: 'Connect',
|
submit: 'Connect',
|
||||||
submitting: 'Connecting…',
|
submitting: 'Connecting…',
|
||||||
|
registerSubmit: 'Create account',
|
||||||
|
registering: 'Creating account…',
|
||||||
|
noAccount: "Don't have an account?",
|
||||||
|
registerLink: 'Sign up',
|
||||||
|
haveAccount: 'Already have an account?',
|
||||||
|
signInLink: 'Sign in',
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
unreachable: "Can't reach this server. Check the URL and that it's online.",
|
unreachable:
|
||||||
|
"Can't reach this server. Check the URL and that it's online.",
|
||||||
badCredentials: 'Incorrect username or password.',
|
badCredentials: 'Incorrect username or password.',
|
||||||
generic: 'Sign-in failed. Please try again.',
|
generic: 'Sign-in failed. Please try again.',
|
||||||
|
usernameTaken: 'That username is already taken.',
|
||||||
|
passwordTooShort: 'Password must be at least 8 characters.',
|
||||||
|
registrationDisabled: 'Registration is disabled on this server.',
|
||||||
|
registerFailed: 'Could not create the account. Please try again.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
library: {
|
library: {
|
||||||
@@ -211,6 +224,8 @@ const en = {
|
|||||||
export default en;
|
export default en;
|
||||||
|
|
||||||
type DeepString<T> = {
|
type DeepString<T> = {
|
||||||
[K in keyof T]: T[K] extends Record<string, unknown> ? DeepString<T[K]> : string;
|
[K in keyof T]: T[K] extends Record<string, unknown>
|
||||||
|
? DeepString<T[K]>
|
||||||
|
: string;
|
||||||
};
|
};
|
||||||
export type Translations = DeepString<typeof en>;
|
export type Translations = DeepString<typeof en>;
|
||||||
|
|||||||
@@ -33,17 +33,29 @@ const ru: Translations = {
|
|||||||
forgetTitle: 'Забыть этот сервер',
|
forgetTitle: 'Забыть этот сервер',
|
||||||
form: {
|
form: {
|
||||||
title: 'Подключиться к серверу',
|
title: 'Подключиться к серверу',
|
||||||
|
registerTitle: 'Создать аккаунт',
|
||||||
serverUrl: 'URL сервера',
|
serverUrl: 'URL сервера',
|
||||||
username: 'Имя пользователя',
|
username: 'Имя пользователя',
|
||||||
password: 'Пароль',
|
password: 'Пароль',
|
||||||
|
passwordHint: 'Не менее 8 символов.',
|
||||||
submit: 'Подключиться',
|
submit: 'Подключиться',
|
||||||
submitting: 'Подключение…',
|
submitting: 'Подключение…',
|
||||||
|
registerSubmit: 'Создать аккаунт',
|
||||||
|
registering: 'Создание аккаунта…',
|
||||||
|
noAccount: 'Нет аккаунта?',
|
||||||
|
registerLink: 'Зарегистрироваться',
|
||||||
|
haveAccount: 'Уже есть аккаунт?',
|
||||||
|
signInLink: 'Войти',
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
unreachable:
|
unreachable:
|
||||||
'Не удаётся подключиться к серверу. Проверьте URL и доступность.',
|
'Не удаётся подключиться к серверу. Проверьте URL и доступность.',
|
||||||
badCredentials: 'Неверное имя пользователя или пароль.',
|
badCredentials: 'Неверное имя пользователя или пароль.',
|
||||||
generic: 'Не удалось войти. Попробуйте ещё раз.',
|
generic: 'Не удалось войти. Попробуйте ещё раз.',
|
||||||
|
usernameTaken: 'Это имя пользователя уже занято.',
|
||||||
|
passwordTooShort: 'Пароль должен содержать не менее 8 символов.',
|
||||||
|
registrationDisabled: 'Регистрация на этом сервере отключена.',
|
||||||
|
registerFailed: 'Не удалось создать аккаунт. Попробуйте ещё раз.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
library: {
|
library: {
|
||||||
|
|||||||
Reference in New Issue
Block a user