mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
feat: add input for api token in playground (#5130)
Adds a token input in playground. In the case of tokens that span multiple projects ie `[]:development.etc` it will look into the token definitions to find the token and get the projects Otherwise it will try to parse the project and environment from the token itself (without checking for it being a valid token) Also, does not support admin tokens `*:*.etc` Closes # [1-1507](https://linear.app/unleash/issue/1-1507/create-a-token-input-in-the-playground-form) <img width="1661" alt="Screenshot 2023-10-23 at 16 38 11" src="https://github.com/Unleash/unleash/assets/104830839/f2d4fb6e-962f-4cc1-b5e4-817fd2de18ff"> <img width="1673" alt="Screenshot 2023-10-23 at 16 38 33" src="https://github.com/Unleash/unleash/assets/104830839/27645955-d651-41e6-be02-4381c4f00551"> <img width="1377" alt="Screenshot 2023-10-25 at 17 06 43" src="https://github.com/Unleash/unleash/assets/104830839/c7638366-3634-4521-af65-4f68a4f3b330"> --------- Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
parent
de540e09f3
commit
8e3863a27e
@ -17,11 +17,11 @@ import {
|
||||
} from './playground.utils';
|
||||
import { PlaygroundGuidance } from './PlaygroundGuidance/PlaygroundGuidance';
|
||||
import { PlaygroundGuidancePopper } from './PlaygroundGuidancePopper/PlaygroundGuidancePopper';
|
||||
import Loader from '../../common/Loader/Loader';
|
||||
import Loader from 'component/common/Loader/Loader';
|
||||
import { AdvancedPlaygroundResultsTable } from './AdvancedPlaygroundResultsTable/AdvancedPlaygroundResultsTable';
|
||||
import { AdvancedPlaygroundResponseSchema } from 'openapi';
|
||||
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||
import { BadRequestError } from '../../../utils/apiUtils';
|
||||
import { BadRequestError } from 'utils/apiUtils';
|
||||
|
||||
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||
marginBottom: theme.spacing(3),
|
||||
@ -34,6 +34,7 @@ export const AdvancedPlayground: VFC<{
|
||||
projects: string[];
|
||||
environments: string[];
|
||||
context?: string;
|
||||
token?: string;
|
||||
} = { projects: [], environments: [] };
|
||||
const { value, setValue } = createLocalStorage(
|
||||
'AdvancedPlayground:v1',
|
||||
@ -49,6 +50,7 @@ export const AdvancedPlayground: VFC<{
|
||||
value.environments,
|
||||
);
|
||||
const [projects, setProjects] = useState<string[]>(value.projects);
|
||||
const [token, setToken] = useState<string | undefined>(value.token);
|
||||
const [context, setContext] = useState<string | undefined>(value.context);
|
||||
const [results, setResults] = useState<
|
||||
AdvancedPlaygroundResponseSchema | undefined
|
||||
@ -76,6 +78,7 @@ export const AdvancedPlayground: VFC<{
|
||||
const environments = resolveEnvironmentsFromUrl();
|
||||
const projects = resolveProjectsFromUrl();
|
||||
const context = resolveContextFromUrl();
|
||||
const token = resolveTokenFromUrl();
|
||||
const makePlaygroundRequest = async () => {
|
||||
if (environments && context) {
|
||||
await evaluatePlaygroundContext(
|
||||
@ -124,6 +127,15 @@ export const AdvancedPlayground: VFC<{
|
||||
return contextFromUrl;
|
||||
};
|
||||
|
||||
const resolveTokenFromUrl = () => {
|
||||
let tokenFromUrl = searchParams.get('token');
|
||||
if (tokenFromUrl) {
|
||||
tokenFromUrl = decodeURI(tokenFromUrl);
|
||||
setToken(tokenFromUrl);
|
||||
}
|
||||
return tokenFromUrl;
|
||||
};
|
||||
|
||||
const evaluatePlaygroundContext = async (
|
||||
environments: string[] | string,
|
||||
projects: string[] | string,
|
||||
@ -249,6 +261,8 @@ export const AdvancedPlayground: VFC<{
|
||||
availableEnvironments={availableEnvironments}
|
||||
projects={projects}
|
||||
environments={environments}
|
||||
token={token}
|
||||
setToken={setToken}
|
||||
setProjects={setProjects}
|
||||
setEnvironments={setEnvironments}
|
||||
/>
|
||||
|
@ -0,0 +1,150 @@
|
||||
import { testServerRoute, testServerSetup } from 'utils/testServer';
|
||||
import { render } from 'utils/testRenderer';
|
||||
import { fireEvent, screen, within } from '@testing-library/react';
|
||||
import { PlaygroundConnectionFieldset } from './PlaygroundConnectionFieldset';
|
||||
import { useState } from 'react';
|
||||
|
||||
const server = testServerSetup();
|
||||
|
||||
beforeEach(() => {
|
||||
testServerRoute(server, '/api/admin/ui-config', {
|
||||
versionInfo: {
|
||||
current: { oss: 'version', enterprise: 'version' },
|
||||
},
|
||||
flags: {
|
||||
playgroundImprovements: true,
|
||||
},
|
||||
});
|
||||
testServerRoute(
|
||||
server,
|
||||
'/api/admin/projects',
|
||||
{
|
||||
projects: [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
},
|
||||
{
|
||||
id: 'MyProject',
|
||||
name: 'MyProject',
|
||||
},
|
||||
],
|
||||
},
|
||||
'get',
|
||||
200,
|
||||
);
|
||||
testServerRoute(
|
||||
server,
|
||||
'/api/admin/api-tokens',
|
||||
{
|
||||
tokens: [
|
||||
{
|
||||
secret: '[]:development.964a287e1b728cb5f4f3e0120df92cb5',
|
||||
projects: ['default', 'MyProject'],
|
||||
},
|
||||
],
|
||||
},
|
||||
'get',
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
const Component = () => {
|
||||
const [environments, setEnvironments] = useState<string[]>([]);
|
||||
const [projects, setProjects] = useState<string[]>([]);
|
||||
const [token, setToken] = useState<string>();
|
||||
|
||||
const availableEnvironments = ['development', 'production'];
|
||||
|
||||
return (
|
||||
<PlaygroundConnectionFieldset
|
||||
environments={environments}
|
||||
projects={projects}
|
||||
token={token}
|
||||
setToken={setToken}
|
||||
setEnvironments={setEnvironments}
|
||||
setProjects={setProjects}
|
||||
availableEnvironments={availableEnvironments}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
test('should parse project and environment from token input', async () => {
|
||||
render(<Component />);
|
||||
|
||||
const tokenInput = await screen.findByLabelText('Api token');
|
||||
fireEvent.change(tokenInput, {
|
||||
target: {
|
||||
value: 'default:development.964a287e1b728cb5f4f3e0120df92cb5',
|
||||
},
|
||||
});
|
||||
|
||||
const projectAutocomplete = await screen.findByTestId(
|
||||
'PLAYGROUND_PROJECT_SELECT',
|
||||
);
|
||||
const projectInput = within(projectAutocomplete).getByRole('combobox');
|
||||
|
||||
const environmentAutocomplete = await screen.findByTestId(
|
||||
'PLAYGROUND_ENVIRONMENT_SELECT',
|
||||
);
|
||||
const environmentInput = within(environmentAutocomplete).getByRole(
|
||||
'combobox',
|
||||
);
|
||||
|
||||
expect(projectInput).toBeDisabled();
|
||||
expect(environmentInput).toBeDisabled();
|
||||
await within(projectAutocomplete).findByText('Default');
|
||||
await within(environmentAutocomplete).findByText('development');
|
||||
});
|
||||
|
||||
test('should load projects from token definition if project is []', async () => {
|
||||
render(<Component />);
|
||||
|
||||
const tokenInput = await screen.findByLabelText('Api token');
|
||||
fireEvent.change(tokenInput, {
|
||||
target: { value: '[]:development.964a287e1b728cb5f4f3e0120df92cb5' },
|
||||
});
|
||||
|
||||
const projectAutocomplete = await screen.findByTestId(
|
||||
'PLAYGROUND_PROJECT_SELECT',
|
||||
);
|
||||
const projectInput = within(projectAutocomplete).getByRole('combobox');
|
||||
|
||||
const environmentAutocomplete = await screen.findByTestId(
|
||||
'PLAYGROUND_ENVIRONMENT_SELECT',
|
||||
);
|
||||
const environmentInput = within(environmentAutocomplete).getByRole(
|
||||
'combobox',
|
||||
);
|
||||
|
||||
expect(projectInput).toBeDisabled();
|
||||
expect(environmentInput).toBeDisabled();
|
||||
await within(projectAutocomplete).findByText('Default');
|
||||
await within(projectAutocomplete).findByText('MyProject');
|
||||
await within(environmentAutocomplete).findByText('development');
|
||||
});
|
||||
|
||||
test('should show an error when admin token', async () => {
|
||||
render(<Component />);
|
||||
|
||||
const tokenInput = await screen.findByLabelText('Api token');
|
||||
fireEvent.change(tokenInput, {
|
||||
target: { value: '*:*.964a287e1b728cb5f4f3e0120df92cb5' },
|
||||
});
|
||||
|
||||
const projectAutocomplete = await screen.findByTestId(
|
||||
'PLAYGROUND_PROJECT_SELECT',
|
||||
);
|
||||
const projectInput = within(projectAutocomplete).getByRole('combobox');
|
||||
|
||||
const environmentAutocomplete = await screen.findByTestId(
|
||||
'PLAYGROUND_ENVIRONMENT_SELECT',
|
||||
);
|
||||
const environmentInput = within(environmentAutocomplete).getByRole(
|
||||
'combobox',
|
||||
);
|
||||
|
||||
expect(projectInput).toBeDisabled();
|
||||
expect(environmentInput).toBeDisabled();
|
||||
await screen.findByText('Admin tokens are not supported in the playground');
|
||||
});
|
@ -1,19 +1,33 @@
|
||||
import React, { ComponentProps, VFC } from 'react';
|
||||
import React, { ComponentProps, useState, VFC } from 'react';
|
||||
import {
|
||||
Autocomplete,
|
||||
Box,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||
import { renderOption } from '../renderOption';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
import {
|
||||
IApiToken,
|
||||
useApiTokens,
|
||||
} from 'hooks/api/getters/useApiTokens/useApiTokens';
|
||||
import Input from 'component/common/Input/Input';
|
||||
import {
|
||||
extractProjectEnvironmentFromToken,
|
||||
validateTokenFormat,
|
||||
} from '../../playground.utils';
|
||||
|
||||
interface IPlaygroundConnectionFieldsetProps {
|
||||
environments: string[];
|
||||
projects: string[];
|
||||
token?: string;
|
||||
setProjects: (projects: string[]) => void;
|
||||
setEnvironments: (environments: string[]) => void;
|
||||
setToken?: (token: string) => void;
|
||||
availableEnvironments: string[];
|
||||
}
|
||||
|
||||
@ -29,11 +43,16 @@ export const PlaygroundConnectionFieldset: VFC<
|
||||
> = ({
|
||||
environments,
|
||||
projects,
|
||||
token,
|
||||
setProjects,
|
||||
setEnvironments,
|
||||
setToken,
|
||||
availableEnvironments,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const playgroundImprovements = useUiFlag('playgroundImprovements');
|
||||
const { tokens } = useApiTokens();
|
||||
const [tokenError, setTokenError] = useState<string | undefined>();
|
||||
|
||||
const { projects: availableProjects = [] } = useProjects();
|
||||
const projectsOptions = [
|
||||
@ -96,12 +115,92 @@ export const PlaygroundConnectionFieldset: VFC<
|
||||
};
|
||||
|
||||
const isAllProjects =
|
||||
projects.length === 0 || (projects.length === 1 && projects[0] === '*');
|
||||
projects &&
|
||||
(projects.length === 0 ||
|
||||
(projects.length === 1 && projects[0] === '*'));
|
||||
|
||||
const envValue = environmentOptions.filter(({ id }) =>
|
||||
environments.includes(id),
|
||||
);
|
||||
|
||||
const onSetToken: ComponentProps<typeof TextField>['onChange'] = async (
|
||||
event,
|
||||
) => {
|
||||
const tempToken = event.target.value;
|
||||
setToken?.(tempToken);
|
||||
|
||||
if (tempToken === '') {
|
||||
resetTokenState();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
validateTokenFormat(tempToken);
|
||||
setTokenError(undefined);
|
||||
processToken(tempToken);
|
||||
} catch (e: any) {
|
||||
setTokenError(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const processToken = (tempToken: string) => {
|
||||
const [tokenProject, tokenEnvironment] =
|
||||
extractProjectEnvironmentFromToken(tempToken);
|
||||
setEnvironments([tokenEnvironment]);
|
||||
|
||||
switch (tokenProject) {
|
||||
case '[]':
|
||||
handleTokenWithSomeProjects(tempToken);
|
||||
break;
|
||||
case '*':
|
||||
handleTokenWithAllProjects();
|
||||
break;
|
||||
default:
|
||||
handleSpecificProjectToken(tokenProject);
|
||||
}
|
||||
};
|
||||
|
||||
const updateProjectsBasedOnValidToken = (validToken: IApiToken) => {
|
||||
if (!validToken.projects || validToken.projects === '*') {
|
||||
setProjects([allOption.id]);
|
||||
} else if (typeof validToken.projects === 'string') {
|
||||
setProjects([validToken.projects]);
|
||||
} else if (Array.isArray(validToken.projects)) {
|
||||
setProjects(validToken.projects);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTokenWithSomeProjects = (tempToken: string) => {
|
||||
const validToken = tokens.find(({ secret }) => secret === tempToken);
|
||||
if (validToken) {
|
||||
updateProjectsBasedOnValidToken(validToken);
|
||||
} else {
|
||||
setTokenError(
|
||||
'Invalid token. Ensure you use a valid token from this Unleash instance.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTokenWithAllProjects = () => {
|
||||
setProjects([allOption.id]);
|
||||
};
|
||||
|
||||
const handleSpecificProjectToken = (tokenProject: string) => {
|
||||
if (
|
||||
!projectsOptions.map((option) => option.id).includes(tokenProject)
|
||||
) {
|
||||
setTokenError(
|
||||
`Invalid token. Project ${tokenProject} does not exist.`,
|
||||
);
|
||||
} else {
|
||||
setProjects([tokenProject]);
|
||||
}
|
||||
};
|
||||
|
||||
const resetTokenState = () => {
|
||||
setTokenError(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ pb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
@ -114,49 +213,83 @@ export const PlaygroundConnectionFieldset: VFC<
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
limitTags={3}
|
||||
id='environment'
|
||||
multiple={true}
|
||||
options={environmentOptions}
|
||||
sx={{ flex: 1 }}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label='Environments' />
|
||||
)}
|
||||
renderOption={renderOption}
|
||||
getOptionLabel={({ label }) => label}
|
||||
disableCloseOnSelect={false}
|
||||
size='small'
|
||||
value={envValue}
|
||||
onChange={onEnvironmentsChange}
|
||||
data-testid={'PLAYGROUND_ENVIRONMENT_SELECT'}
|
||||
/>
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
id='projects'
|
||||
limitTags={3}
|
||||
multiple={!isAllProjects}
|
||||
options={projectsOptions}
|
||||
sx={{ flex: 1 }}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label='Projects' />
|
||||
)}
|
||||
renderOption={renderOption}
|
||||
getOptionLabel={({ label }) => label}
|
||||
disableCloseOnSelect
|
||||
size='small'
|
||||
value={
|
||||
isAllProjects
|
||||
? allOption
|
||||
: projectsOptions.filter(({ id }) =>
|
||||
projects.includes(id),
|
||||
)
|
||||
<Tooltip
|
||||
title={
|
||||
token
|
||||
? 'Environment is automatically selected because you are using a token'
|
||||
: 'Select environments to use in the playground'
|
||||
}
|
||||
onChange={onProjectsChange}
|
||||
data-testid={'PLAYGROUND_PROJECT_SELECT'}
|
||||
/>
|
||||
>
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
limitTags={3}
|
||||
id='environment'
|
||||
multiple={true}
|
||||
options={environmentOptions}
|
||||
sx={{ flex: 1 }}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label='Environments' />
|
||||
)}
|
||||
renderOption={renderOption}
|
||||
getOptionLabel={({ label }) => label}
|
||||
disableCloseOnSelect={false}
|
||||
size='small'
|
||||
value={envValue}
|
||||
onChange={onEnvironmentsChange}
|
||||
disabled={Boolean(token)}
|
||||
data-testid={'PLAYGROUND_ENVIRONMENT_SELECT'}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={
|
||||
token
|
||||
? 'Project is automatically selected because you are using a token'
|
||||
: 'Select projects to use in the playground'
|
||||
}
|
||||
>
|
||||
<Autocomplete
|
||||
disablePortal
|
||||
id='projects'
|
||||
limitTags={3}
|
||||
multiple={!isAllProjects}
|
||||
options={projectsOptions}
|
||||
sx={{ flex: 1 }}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label='Projects' />
|
||||
)}
|
||||
renderOption={renderOption}
|
||||
getOptionLabel={({ label }) => label}
|
||||
disableCloseOnSelect
|
||||
size='small'
|
||||
value={
|
||||
isAllProjects
|
||||
? allOption
|
||||
: projectsOptions.filter(({ id }) =>
|
||||
projects.includes(id),
|
||||
)
|
||||
}
|
||||
onChange={onProjectsChange}
|
||||
disabled={Boolean(token)}
|
||||
data-testid={'PLAYGROUND_PROJECT_SELECT'}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(playgroundImprovements)}
|
||||
show={
|
||||
<Input
|
||||
sx={{ mt: 2, width: '50%', pr: 1 }}
|
||||
label='Api token'
|
||||
value={token || ''}
|
||||
onChange={onSetToken}
|
||||
type={'text'}
|
||||
error={Boolean(tokenError)}
|
||||
errorText={tokenError}
|
||||
placeholder={'Enter your api token'}
|
||||
data-testid={'PLAYGROUND_TOKEN_INPUT'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -9,6 +9,8 @@ interface IPlaygroundFormProps {
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
environments: string | string[];
|
||||
projects: string[];
|
||||
token?: string;
|
||||
setToken?: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
setProjects: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
setEnvironments: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
context: string | undefined;
|
||||
@ -20,6 +22,8 @@ export const PlaygroundForm: VFC<IPlaygroundFormProps> = ({
|
||||
environments,
|
||||
onSubmit,
|
||||
projects,
|
||||
token,
|
||||
setToken,
|
||||
setProjects,
|
||||
setEnvironments,
|
||||
context,
|
||||
@ -39,6 +43,8 @@ export const PlaygroundForm: VFC<IPlaygroundFormProps> = ({
|
||||
Array.isArray(environments) ? environments : [environments]
|
||||
}
|
||||
projects={projects}
|
||||
token={token}
|
||||
setToken={setToken}
|
||||
setEnvironments={setEnvironments}
|
||||
setProjects={setProjects}
|
||||
availableEnvironments={availableEnvironments.map(
|
||||
|
@ -1,6 +1,7 @@
|
||||
import {
|
||||
normalizeCustomContextProperties,
|
||||
NormalizedContextProperties,
|
||||
validateTokenFormat,
|
||||
} from './playground.utils';
|
||||
|
||||
test('should keep standard properties in their place', () => {
|
||||
@ -76,3 +77,48 @@ test('should add multiple standard properties without breaking custom properties
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTokenFormat', () => {
|
||||
it('should throw an error for invalid token format without colon', () => {
|
||||
const invalidToken = 'invalidToken';
|
||||
expect(() => validateTokenFormat(invalidToken)).toThrow(
|
||||
'Invalid token format',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw an error for invalid token format without period', () => {
|
||||
const invalidToken = 'project:environment';
|
||||
expect(() => validateTokenFormat(invalidToken)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw an error for tokens with an empty project', () => {
|
||||
const invalidToken = ':environment.abc123';
|
||||
expect(() => validateTokenFormat(invalidToken)).toThrow(
|
||||
'Invalid token format',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error for tokens with an empty environment', () => {
|
||||
const invalidToken = 'project:.abc123';
|
||||
expect(() => validateTokenFormat(invalidToken)).toThrow(
|
||||
'Invalid token format',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error for admin tokens', () => {
|
||||
const adminToken = 'project:*.abc123';
|
||||
expect(() => validateTokenFormat(adminToken)).toThrow(
|
||||
'Admin tokens are not supported in the playground',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw an error for valid token formats', () => {
|
||||
const validToken = 'project:environment.abc123';
|
||||
expect(() => validateTokenFormat(validToken)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw an error for valid token format and all projects', () => {
|
||||
const validToken = '*:environment.abc123';
|
||||
expect(() => validateTokenFormat(validToken)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
@ -125,3 +125,20 @@ export const normalizeCustomContextProperties = (
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
export const validateTokenFormat = (token: string): void => {
|
||||
const [projectEnvAccess] = token.split('.');
|
||||
const [project, environment] = projectEnvAccess.split(':');
|
||||
if (!project || !environment) {
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
|
||||
if (environment === '*') {
|
||||
throw new Error('Admin tokens are not supported in the playground');
|
||||
}
|
||||
};
|
||||
|
||||
export const extractProjectEnvironmentFromToken = (token: string) => {
|
||||
const [projectEnvAccess] = token.split('.');
|
||||
return projectEnvAccess.split(':');
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user