mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: feature creation limit crud together with frontend (#4221)
This commit is contained in:
		
							parent
							
								
									c387a19831
								
							
						
					
					
						commit
						3da1cbba47
					
				| @ -216,7 +216,6 @@ const getDeleteButtons = async () => { | ||||
|             deleteButtons.push(...removeButton); | ||||
|         }) | ||||
|     ); | ||||
|     console.log(deleteButtons); | ||||
|     return deleteButtons; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -12,6 +12,24 @@ import UIContext from 'contexts/UIContext'; | ||||
| import { CF_CREATE_BTN_ID } from 'utils/testIds'; | ||||
| import { formatUnknownError } from 'utils/formatUnknownError'; | ||||
| import { GO_BACK } from 'constants/navigate'; | ||||
| import { Alert, styled } from '@mui/material'; | ||||
| import useProject from 'hooks/api/getters/useProject/useProject'; | ||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||
| 
 | ||||
| const StyledAlert = styled(Alert)(({ theme }) => ({ | ||||
|     marginBottom: theme.spacing(2), | ||||
| })); | ||||
| 
 | ||||
| export const isFeatureLimitReached = ( | ||||
|     featureLimit: number | null | undefined, | ||||
|     currentFeatureCount: number | ||||
| ): boolean => { | ||||
|     return ( | ||||
|         featureLimit !== null && | ||||
|         featureLimit !== undefined && | ||||
|         featureLimit <= currentFeatureCount | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| const CreateFeature = () => { | ||||
|     const { setToastData, setToastApiError } = useToast(); | ||||
| @ -36,6 +54,8 @@ const CreateFeature = () => { | ||||
|         errors, | ||||
|     } = useFeatureForm(); | ||||
| 
 | ||||
|     const { project: projectInfo } = useProject(project); | ||||
| 
 | ||||
|     const { createFeatureToggle, loading } = useFeatureApi(); | ||||
| 
 | ||||
|     const handleSubmit = async (e: Event) => { | ||||
| @ -74,6 +94,11 @@ const CreateFeature = () => { | ||||
|         navigate(GO_BACK); | ||||
|     }; | ||||
| 
 | ||||
|     const featureLimitReached = | ||||
|         isFeatureLimitReached( | ||||
|             projectInfo.featureLimit, | ||||
|             projectInfo.features.length | ||||
|         ) && Boolean(uiConfig.flags.newProjectLayout); | ||||
|     return ( | ||||
|         <FormTemplate | ||||
|             loading={loading} | ||||
| @ -84,6 +109,18 @@ const CreateFeature = () => { | ||||
|             documentationLinkLabel="Feature toggle types documentation" | ||||
|             formatApiCode={formatApiCode} | ||||
|         > | ||||
|             <ConditionallyRender | ||||
|                 condition={featureLimitReached} | ||||
|                 show={ | ||||
|                     <StyledAlert severity="error"> | ||||
|                         <strong>Feature toggle project limit reached. </strong>{' '} | ||||
|                         To be able to create more feature toggles in this | ||||
|                         project please increase the feature toggle upper limit | ||||
|                         in the project settings. | ||||
|                     </StyledAlert> | ||||
|                 } | ||||
|             /> | ||||
| 
 | ||||
|             <FeatureForm | ||||
|                 type={type} | ||||
|                 name={name} | ||||
| @ -104,6 +141,7 @@ const CreateFeature = () => { | ||||
|             > | ||||
|                 <CreateButton | ||||
|                     name="feature toggle" | ||||
|                     disabled={featureLimitReached} | ||||
|                     permission={CREATE_FEATURE} | ||||
|                     projectId={project} | ||||
|                     data-testid={CF_CREATE_BTN_ID} | ||||
|  | ||||
| @ -0,0 +1,21 @@ | ||||
| import { isFeatureLimitReached } from './CreateFeature'; | ||||
| 
 | ||||
| test('isFeatureLimitReached  should return false when featureLimit is null', async () => { | ||||
|     expect(isFeatureLimitReached(null, 5)).toBe(false); | ||||
| }); | ||||
| 
 | ||||
| test('isFeatureLimitReached  should return false when featureLimit is undefined', async () => { | ||||
|     expect(isFeatureLimitReached(undefined, 5)).toBe(false); | ||||
| }); | ||||
| 
 | ||||
| test('isFeatureLimitReached should return false when featureLimit is smaller current feature count', async () => { | ||||
|     expect(isFeatureLimitReached(6, 5)).toBe(false); | ||||
| }); | ||||
| 
 | ||||
| test('isFeatureLimitReached should return true when featureLimit is smaller current feature count', async () => { | ||||
|     expect(isFeatureLimitReached(4, 5)).toBe(true); | ||||
| }); | ||||
| 
 | ||||
| test('isFeatureLimitReached should return true when featureLimit is equal to current feature count', async () => { | ||||
|     expect(isFeatureLimitReached(5, 5)).toBe(true); | ||||
| }); | ||||
| @ -236,7 +236,6 @@ const FeatureForm: React.FC<IFeatureToggleForm> = ({ | ||||
|                     </StyledRow> | ||||
|                 </StyledFormControl> | ||||
|             </StyledContainer> | ||||
| 
 | ||||
|             <StyledButtonContainer> | ||||
|                 {children} | ||||
|                 <StyledCancelButton onClick={handleCancel}> | ||||
|  | ||||
| @ -230,8 +230,7 @@ const ProjectForm: React.FC<IProjectForm> = ({ | ||||
|                                 <ConditionallyRender | ||||
|                                     condition={ | ||||
|                                         featureCount !== undefined && | ||||
|                                         featureLimit !== undefined && | ||||
|                                         featureLimit.length > 0 | ||||
|                                         Boolean(featureLimit) | ||||
|                                     } | ||||
|                                     show={ | ||||
|                                         <Box> | ||||
|  | ||||
| @ -54,7 +54,8 @@ const EditProject = () => { | ||||
|         project.name, | ||||
|         project.description, | ||||
|         defaultStickiness, | ||||
|         project.mode | ||||
|         project.mode, | ||||
|         project.featureLimit ? String(project.featureLimit) : '' | ||||
|     ); | ||||
| 
 | ||||
|     const formatApiCode = () => { | ||||
|  | ||||
| @ -57,11 +57,18 @@ const useProjectForm = ( | ||||
|             name: projectName, | ||||
|             description: projectDesc, | ||||
|             defaultStickiness: projectStickiness, | ||||
|             featureLimit: featureLimit, | ||||
|             featureLimit: getFeatureLimitAsNumber(), | ||||
|             mode: projectMode, | ||||
|         }; | ||||
|     }; | ||||
| 
 | ||||
|     const getFeatureLimitAsNumber = () => { | ||||
|         if (featureLimit === '') { | ||||
|             return undefined; | ||||
|         } | ||||
|         return Number(featureLimit); | ||||
|     }; | ||||
| 
 | ||||
|     const validateProjectId = async () => { | ||||
|         if (projectId.length === 0) { | ||||
|             setErrors(prev => ({ ...prev, id: 'Id can not be empty.' })); | ||||
|  | ||||
| @ -26,6 +26,7 @@ export interface IProject { | ||||
|     features: IFeatureToggleListItem[]; | ||||
|     mode: 'open' | 'protected'; | ||||
|     defaultStickiness: string; | ||||
|     featureLimit?: number; | ||||
| } | ||||
| 
 | ||||
| export interface IProjectHealthReport extends IProject { | ||||
|  | ||||
| @ -7,14 +7,12 @@ import { | ||||
|     IFlagResolver, | ||||
|     IProject, | ||||
|     IProjectWithCount, | ||||
|     ProjectMode, | ||||
| } from '../types'; | ||||
| import { | ||||
|     IProjectHealthUpdate, | ||||
|     IProjectInsert, | ||||
|     IProjectQuery, | ||||
|     IProjectSettings, | ||||
|     IProjectSettingsRow, | ||||
|     IProjectStore, | ||||
|     ProjectEnvironment, | ||||
| } from '../types/stores/project-store'; | ||||
| @ -35,7 +33,11 @@ const COLUMNS = [ | ||||
|     'updated_at', | ||||
| ]; | ||||
| const TABLE = 'projects'; | ||||
| const SETTINGS_COLUMNS = ['project_mode', 'default_stickiness']; | ||||
| const SETTINGS_COLUMNS = [ | ||||
|     'project_mode', | ||||
|     'default_stickiness', | ||||
|     'feature_limit', | ||||
| ]; | ||||
| const SETTINGS_TABLE = 'project_settings'; | ||||
| const PROJECT_ENVIRONMENTS = 'project_environments'; | ||||
| 
 | ||||
| @ -94,6 +96,20 @@ class ProjectStore implements IProjectStore { | ||||
|         return present; | ||||
|     } | ||||
| 
 | ||||
|     async isFeatureLimitReached(id: string): Promise<boolean> { | ||||
|         const result = await this.db.raw( | ||||
|             `SELECT EXISTS(SELECT 1
 | ||||
|              FROM project_settings | ||||
|              LEFT JOIN features ON project_settings.project = features.project | ||||
|              WHERE project_settings.project = ? | ||||
|              GROUP BY project_settings.project | ||||
|              HAVING project_settings.feature_limit <= COUNT(features.project)) AS present`,
 | ||||
|             [id], | ||||
|         ); | ||||
|         const { present } = result.rows[0]; | ||||
|         return present; | ||||
|     } | ||||
| 
 | ||||
|     async getProjectsWithCounts( | ||||
|         query?: IProjectQuery, | ||||
|         userId?: number, | ||||
| @ -219,6 +235,7 @@ class ProjectStore implements IProjectStore { | ||||
|                 project: project.id, | ||||
|                 project_mode: project.mode, | ||||
|                 default_stickiness: project.defaultStickiness, | ||||
|                 feature_limit: project.featureLimit, | ||||
|             }) | ||||
|             .returning('*'); | ||||
|         return this.mapRow({ ...row[0], ...settingsRow[0] }); | ||||
| @ -245,12 +262,14 @@ class ProjectStore implements IProjectStore { | ||||
|                     .update({ | ||||
|                         project_mode: data.mode, | ||||
|                         default_stickiness: data.defaultStickiness, | ||||
|                         feature_limit: data.featureLimit, | ||||
|                     }); | ||||
|             } else { | ||||
|                 await this.db(SETTINGS_TABLE).insert({ | ||||
|                     project: data.id, | ||||
|                     project_mode: data.mode, | ||||
|                     default_stickiness: data.defaultStickiness, | ||||
|                     feature_limit: data.featureLimit, | ||||
|                 }); | ||||
|             } | ||||
|         } catch (err) { | ||||
| @ -486,24 +505,6 @@ class ProjectStore implements IProjectStore { | ||||
|         return Number(members.count); | ||||
|     } | ||||
| 
 | ||||
|     async getProjectSettings(projectId: string): Promise<IProjectSettings> { | ||||
|         const row = await this.db(SETTINGS_TABLE).where({ project: projectId }); | ||||
|         return this.mapSettingsRow(row[0]); | ||||
|     } | ||||
| 
 | ||||
|     async setProjectSettings( | ||||
|         projectId: string, | ||||
|         defaultStickiness: string, | ||||
|         mode: ProjectMode, | ||||
|     ): Promise<void> { | ||||
|         await this.db(SETTINGS_TABLE) | ||||
|             .update({ | ||||
|                 default_stickiness: defaultStickiness, | ||||
|                 project_mode: mode, | ||||
|             }) | ||||
|             .where({ project: projectId }); | ||||
|     } | ||||
| 
 | ||||
|     async getDefaultStrategy( | ||||
|         projectId: string, | ||||
|         environment: string, | ||||
| @ -537,13 +538,6 @@ class ProjectStore implements IProjectStore { | ||||
|             .then((res) => Number(res[0].count)); | ||||
|     } | ||||
| 
 | ||||
|     mapSettingsRow(row?: IProjectSettingsRow): IProjectSettings { | ||||
|         return { | ||||
|             defaultStickiness: row?.default_stickiness || 'default', | ||||
|             mode: row?.project_mode || 'open', | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 | ||||
|     mapLinkRow(row): IEnvironmentProjectLink { | ||||
|         return { | ||||
| @ -567,6 +561,7 @@ class ProjectStore implements IProjectStore { | ||||
|             updatedAt: row.updated_at || new Date(), | ||||
|             mode: row.project_mode || 'open', | ||||
|             defaultStickiness: row.default_stickiness || 'default', | ||||
|             featureLimit: row.feature_limit, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -12,7 +12,7 @@ import { | ||||
|     UPDATE_FEATURE_ENVIRONMENT_VARIANTS, | ||||
|     UPDATE_TAG_TYPE, | ||||
| } from '../../types'; | ||||
| import { InvalidOperationError } from '../../error'; | ||||
| import { PermissionError } from '../../error'; | ||||
| 
 | ||||
| type Mode = 'regular' | 'change_request'; | ||||
| 
 | ||||
| @ -149,9 +149,7 @@ export class ImportPermissionsService { | ||||
|             mode, | ||||
|         ); | ||||
|         if (missingPermissions.length > 0) { | ||||
|             throw new InvalidOperationError( | ||||
|                 'You are missing permissions to import', | ||||
|             ); | ||||
|             throw new PermissionError(missingPermissions); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -56,6 +56,13 @@ export const healthOverviewSchema = { | ||||
|             description: | ||||
|                 "The project's [collaboration mode](https://docs.getunleash.io/reference/project-collaboration-mode). Determines whether non-project members can submit change requests or not.", | ||||
|         }, | ||||
|         featureLimit: { | ||||
|             type: 'number', | ||||
|             nullable: true, | ||||
|             example: 100, | ||||
|             description: | ||||
|                 'A limit on the number of features allowed in the project. Null if no limit.', | ||||
|         }, | ||||
|         members: { | ||||
|             type: 'integer', | ||||
|             description: 'The number of users/members in the project.', | ||||
|  | ||||
| @ -51,6 +51,13 @@ export const projectOverviewSchema = { | ||||
|             description: | ||||
|                 "The project's [collaboration mode](https://docs.getunleash.io/reference/project-collaboration-mode). Determines whether non-project members can submit change requests or not.", | ||||
|         }, | ||||
|         featureLimit: { | ||||
|             type: 'number', | ||||
|             nullable: true, | ||||
|             example: 100, | ||||
|             description: | ||||
|                 'A limit on the number of features allowed in the project. Null if no limit.', | ||||
|         }, | ||||
|         members: { | ||||
|             type: 'number', | ||||
|             example: 4, | ||||
|  | ||||
| @ -921,6 +921,15 @@ class FeatureToggleService { | ||||
|         this.logger.info(`${createdBy} creates feature toggle ${value.name}`); | ||||
|         await this.validateName(value.name); | ||||
|         const exists = await this.projectStore.hasProject(projectId); | ||||
| 
 | ||||
|         if ( | ||||
|             this.flagResolver.isEnabled('newProjectLayout') && | ||||
|             (await this.projectStore.isFeatureLimitReached(projectId)) | ||||
|         ) { | ||||
|             throw new InvalidOperationError( | ||||
|                 'You have reached the maximum number of feature toggles for this project.', | ||||
|             ); | ||||
|         } | ||||
|         if (exists) { | ||||
|             let featureData; | ||||
|             if (isValidated) { | ||||
|  | ||||
| @ -9,5 +9,6 @@ export const projectSchema = joi | ||||
|         description: joi.string().allow(null).allow('').optional(), | ||||
|         mode: joi.string().valid('open', 'protected').default('open'), | ||||
|         defaultStickiness: joi.string().default('default'), | ||||
|         featureLimit: joi.number().allow(null).optional(), | ||||
|     }) | ||||
|     .options({ allowUnknown: false, stripUnknown: true }); | ||||
|  | ||||
| @ -29,7 +29,6 @@ import { | ||||
|     ProjectGroupAddedEvent, | ||||
|     ProjectGroupRemovedEvent, | ||||
|     ProjectGroupUpdateRoleEvent, | ||||
|     ProjectMode, | ||||
|     ProjectUserAddedEvent, | ||||
|     ProjectUserRemovedEvent, | ||||
|     ProjectUserUpdateRoleEvent, | ||||
| @ -37,11 +36,7 @@ import { | ||||
|     IFlagResolver, | ||||
|     ProjectAccessAddedEvent, | ||||
| } from '../types'; | ||||
| import { | ||||
|     IProjectQuery, | ||||
|     IProjectSettings, | ||||
|     IProjectStore, | ||||
| } from '../types/stores/project-store'; | ||||
| import { IProjectQuery, IProjectStore } from '../types/stores/project-store'; | ||||
| import { | ||||
|     IProjectAccessModel, | ||||
|     IRoleDescriptor, | ||||
| @ -836,6 +831,7 @@ export default class ProjectService { | ||||
|             name: project.name, | ||||
|             description: project.description, | ||||
|             mode: project.mode, | ||||
|             featureLimit: project.featureLimit, | ||||
|             defaultStickiness: project.defaultStickiness, | ||||
|             health: project.health || 0, | ||||
|             favorite: favorite, | ||||
| @ -847,20 +843,4 @@ export default class ProjectService { | ||||
|             version: 1, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     async getProjectSettings(projectId: string): Promise<IProjectSettings> { | ||||
|         return this.store.getProjectSettings(projectId); | ||||
|     } | ||||
| 
 | ||||
|     async setProjectSettings( | ||||
|         projectId: string, | ||||
|         defaultStickiness: string, | ||||
|         mode: ProjectMode, | ||||
|     ): Promise<void> { | ||||
|         return this.store.setProjectSettings( | ||||
|             projectId, | ||||
|             defaultStickiness, | ||||
|             mode, | ||||
|         ); | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -196,7 +196,7 @@ export interface IProjectOverview { | ||||
|     createdAt: Date | undefined; | ||||
|     stats?: IProjectStats; | ||||
|     mode: ProjectMode; | ||||
| 
 | ||||
|     featureLimit?: number; | ||||
|     defaultStickiness: string; | ||||
| } | ||||
| 
 | ||||
| @ -384,6 +384,7 @@ export interface IProject { | ||||
|     changeRequestsEnabled?: boolean; | ||||
|     mode: ProjectMode; | ||||
|     defaultStickiness: string; | ||||
|     featureLimit?: number; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | ||||
| @ -18,11 +18,13 @@ export interface IProjectInsert { | ||||
|     updatedAt?: Date; | ||||
|     changeRequestsEnabled?: boolean; | ||||
|     mode: ProjectMode; | ||||
|     featureLimit?: number; | ||||
| } | ||||
| 
 | ||||
| export interface IProjectSettings { | ||||
|     mode: ProjectMode; | ||||
|     defaultStickiness: string; | ||||
|     featureLimit?: number; | ||||
| } | ||||
| 
 | ||||
| export interface IProjectSettingsRow { | ||||
| @ -55,11 +57,6 @@ export type ProjectEnvironment = { | ||||
|     defaultStrategy?: CreateFeatureStrategySchema; | ||||
| }; | ||||
| 
 | ||||
| export interface IProjectEnvironmentWithChangeRequests { | ||||
|     environment: string; | ||||
|     changeRequestsEnabled: boolean; | ||||
| } | ||||
| 
 | ||||
| export interface IProjectStore extends Store<IProject, string> { | ||||
|     hasProject(id: string): Promise<boolean>; | ||||
| 
 | ||||
| @ -109,13 +106,6 @@ export interface IProjectStore extends Store<IProject, string> { | ||||
|         projects: string[], | ||||
|     ): Promise<void>; | ||||
| 
 | ||||
|     getProjectSettings(projectId: string): Promise<IProjectSettings>; | ||||
|     setProjectSettings( | ||||
|         projectId: string, | ||||
|         defaultStickiness: string, | ||||
|         mode: ProjectMode, | ||||
|     ): Promise<void>; | ||||
| 
 | ||||
|     getDefaultStrategy( | ||||
|         projectId: string, | ||||
|         environment: string, | ||||
| @ -125,4 +115,6 @@ export interface IProjectStore extends Store<IProject, string> { | ||||
|         environment: string, | ||||
|         strategy: CreateFeatureStrategySchema, | ||||
|     ): Promise<CreateFeatureStrategySchema>; | ||||
| 
 | ||||
|     isFeatureLimitReached(id: string): Promise<boolean>; | ||||
| } | ||||
|  | ||||
							
								
								
									
										20
									
								
								src/migrations/20230711163311-project-feature-limit.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								src/migrations/20230711163311-project-feature-limit.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | ||||
| 'use strict'; | ||||
| 
 | ||||
| exports.up = function (db, cb) { | ||||
|     db.runSql( | ||||
|         ` | ||||
|         ALTER TABLE project_settings | ||||
|             ADD COLUMN IF NOT EXISTS "feature_limit" integer; | ||||
|         `,
 | ||||
|         cb(), | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| exports.down = function (db, cb) { | ||||
|     db.runSql( | ||||
|         ` | ||||
|         ALTER TABLE project_settings DROP COLUMN IF EXISTS "feature_limit"; | ||||
|         `,
 | ||||
|         cb, | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										29
									
								
								src/test/fixtures/fake-project-store.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										29
									
								
								src/test/fixtures/fake-project-store.ts
									
									
									
									
										vendored
									
									
								
							| @ -1,16 +1,10 @@ | ||||
| import { | ||||
|     IProjectHealthUpdate, | ||||
|     IProjectInsert, | ||||
|     IProjectSettings, | ||||
|     IProjectStore, | ||||
|     ProjectEnvironment, | ||||
| } from '../../lib/types/stores/project-store'; | ||||
| import { | ||||
|     IEnvironment, | ||||
|     IProject, | ||||
|     IProjectWithCount, | ||||
|     ProjectMode, | ||||
| } from '../../lib/types'; | ||||
| import { IEnvironment, IProject, IProjectWithCount } from '../../lib/types'; | ||||
| import NotFoundError from '../../lib/error/notfound-error'; | ||||
| import { | ||||
|     IEnvironmentProjectLink, | ||||
| @ -167,22 +161,6 @@ export default class FakeProjectStore implements IProjectStore { | ||||
|         throw new Error('Method not implemented'); | ||||
|     } | ||||
| 
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|     getProjectSettings(projectId: string): Promise<IProjectSettings> { | ||||
|         throw new Error('Method not implemented.'); | ||||
|     } | ||||
| 
 | ||||
|     setProjectSettings( | ||||
|         // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|         projectId: string, | ||||
|         // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|         defaultStickiness: string, | ||||
|         // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|         mode: ProjectMode, | ||||
|     ): Promise<void> { | ||||
|         throw new Error('Method not implemented.'); | ||||
|     } | ||||
| 
 | ||||
|     updateDefaultStrategy( | ||||
|         // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|         projectId: string, | ||||
| @ -202,4 +180,9 @@ export default class FakeProjectStore implements IProjectStore { | ||||
|     ): Promise<CreateFeatureStrategySchema | undefined> { | ||||
|         throw new Error('Method not implemented.'); | ||||
|     } | ||||
| 
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-unused-vars
 | ||||
|     isFeatureLimitReached(id: string): Promise<boolean> { | ||||
|         return Promise.resolve(false); | ||||
|     } | ||||
| } | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user