mirror of
https://github.com/Combodo/iTop.git
synced 2026-08-21 05:38:18 +02:00
WIP
This commit is contained in:
@@ -1541,8 +1541,8 @@ class UserRights
|
||||
*/
|
||||
public static function IsActionAllowed($sClass, $iActionCode, $oInstanceSet = null, $oUser = null)
|
||||
{
|
||||
// When initializing, we need to let everything pass trough
|
||||
if (!self::CheckLogin()) {
|
||||
// When initializing, we need to let everything pass through
|
||||
if (is_null($oUser) && !self::CheckLogin()) {
|
||||
return UR_ALLOWED_YES;
|
||||
}
|
||||
|
||||
|
||||
@@ -216,6 +216,14 @@ Operators:<br/>
|
||||
'Core:Context=GUI:Console' => 'Console',
|
||||
'Core:Context=CRON' => 'cron',
|
||||
'Core:Context=GUI:Portal' => 'Portal',
|
||||
|
||||
'Core:GetQuota:Error' => 'Error while getting %1$s quota',
|
||||
'Core:ConsoleUsers' => 'console users',
|
||||
'Core:DisabledUsers' => 'disabled users',
|
||||
'Core:PortalUsers' => 'portal users',
|
||||
'Core:BusinessPartnerUser' => 'business partner users',
|
||||
'Core:ReadOnlyUsers' => 'read-only users',
|
||||
'Core:ApplicationUsers' => 'application users',
|
||||
]);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -161,6 +161,14 @@ Opérateurs :<br/>
|
||||
'Core:Context=CRON+' => 'cron',
|
||||
'Core:Context=GUI:Portal' => 'Portal',
|
||||
'Core:Context=GUI:Portal+' => 'GUI:Portal',
|
||||
|
||||
'Core:GetQuota:Error' => 'Erreur lors de la récupération du quota des %1$s',
|
||||
'Core:ConsoleUsers' => 'utilisateurs console',
|
||||
'Core:DisabledUsers' => 'utilisateurs désactivés',
|
||||
'Core:PortalUsers' => 'utilisateurs du portail',
|
||||
'Core:BusinessPartnerUser' => 'utilisateurs partenaires business',
|
||||
'Core:ReadOnlyUsers' => 'utilisateurs en lecture seule',
|
||||
'Core:ApplicationUsers' => 'utilisateurs applicatifs',
|
||||
]);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -651,6 +651,7 @@ return array(
|
||||
'Combodo\\iTop\\SessionTracker\\SessionGC' => $baseDir . '/sources/SessionTracker/SessionGC.php',
|
||||
'Combodo\\iTop\\SessionTracker\\SessionHandler' => $baseDir . '/sources/SessionTracker/SessionHandler.php',
|
||||
'Combodo\\iTop\\SessionTracker\\iSessionHandlerExtension' => $baseDir . '/sources/SessionTracker/iSessionHandlerExtension.php',
|
||||
'Combodo\\iTop\\Users\\ITopUserQuotaRepository' => $baseDir . '/sources/Users/ITopUserQuotaRepository.php',
|
||||
'CompileCSSService' => $baseDir . '/application/compilecssservice.class.inc.php',
|
||||
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
|
||||
'Config' => $baseDir . '/core/config.class.inc.php',
|
||||
|
||||
@@ -1052,6 +1052,7 @@ class ComposerStaticInitfc0e9e9dea11dcbb6272414776c30685
|
||||
'Combodo\\iTop\\SessionTracker\\SessionGC' => __DIR__ . '/../..' . '/sources/SessionTracker/SessionGC.php',
|
||||
'Combodo\\iTop\\SessionTracker\\SessionHandler' => __DIR__ . '/../..' . '/sources/SessionTracker/SessionHandler.php',
|
||||
'Combodo\\iTop\\SessionTracker\\iSessionHandlerExtension' => __DIR__ . '/../..' . '/sources/SessionTracker/iSessionHandlerExtension.php',
|
||||
'Combodo\\iTop\\Users\\ITopUserQuotaRepository' => __DIR__ . '/../..' . '/sources/Users/ITopUserQuotaRepository.php',
|
||||
'CompileCSSService' => __DIR__ . '/../..' . '/application/compilecssservice.class.inc.php',
|
||||
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
|
||||
'Config' => __DIR__ . '/../..' . '/core/config.class.inc.php',
|
||||
|
||||
221
sources/Users/ITopUserQuotaRepository.php
Normal file
221
sources/Users/ITopUserQuotaRepository.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace Combodo\iTop\Users;
|
||||
|
||||
use CoreException;
|
||||
use CoreUnexpectedValue;
|
||||
use DBObjectSearch;
|
||||
use DBObjectSet;
|
||||
use DBSearch;
|
||||
use DBUnionSearch;
|
||||
use Dict;
|
||||
use DictExceptionMissingString;
|
||||
use Exception;
|
||||
use IssueLog;
|
||||
use MetaModel;
|
||||
use MySQLException;
|
||||
use User;
|
||||
use UserRights;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
class ITopUserQuotaRepository
|
||||
{
|
||||
/**
|
||||
* @param string $sExcludedUsers
|
||||
* @param string $sExcludedProfiles
|
||||
* @param bool $bAllData
|
||||
* @param string $sExcludedFinalClasses
|
||||
*
|
||||
* @return DBObjectSearch|DBUnionSearch|null
|
||||
* @throws Exception
|
||||
*/
|
||||
public function GetConsoleUsers(string $sExcludedUsers = '', string $sExcludedProfiles = '', bool $bAllData = true, string $sExcludedFinalClasses = 'UserToken, UserRemoteSaaS'): null|DBObjectSearch|DBUnionSearch
|
||||
{
|
||||
$sOQLInQuotaUser = "
|
||||
SELECT User AS u
|
||||
WHERE u.status != 'disabled'
|
||||
AND u.login NOT IN ('$sExcludedUsers')
|
||||
AND u.finalclass != ' $sExcludedFinalClasses '
|
||||
AND id NOT IN (
|
||||
SELECT User AS uex
|
||||
JOIN URP_UserProfile AS uup ON uup.userid = uex.id
|
||||
JOIN URP_Profiles AS up ON uup.profileid = up.id
|
||||
WHERE up.name IN ('$sExcludedProfiles'))
|
||||
";
|
||||
try {
|
||||
$oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOQLInQuotaUser) : DBObjectSearch::FromOQL($sOQLInQuotaUser);
|
||||
}
|
||||
catch (Exception $e) {
|
||||
IssueLog::Error('Core:GetConsoleUsersQuota:Error : '.$e->getMessage());
|
||||
throw new Exception(Dict::Format('Core:GetQuota:Error', Dict::S('Core:ConsoleUsers')));
|
||||
}
|
||||
|
||||
// TODO remove read only users
|
||||
return $oFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function GetApplicationUsers(bool $bAllData = true): null|DBObjectSearch|DBUnionSearch
|
||||
{
|
||||
$sOQLApplicationUser = 'SELECT UserToken';
|
||||
try {
|
||||
$oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOQLApplicationUser) : DBObjectSearch::FromOQL($sOQLApplicationUser);
|
||||
}
|
||||
catch (Exception $e) {
|
||||
IssueLog::Error('Core:GetConsoleUsersQuota:Error : '.$e->getMessage());
|
||||
throw new Exception(Dict::Format('Core:GetQuota:Error', Dict::S('Core:ApplicationUsers')));
|
||||
}
|
||||
|
||||
return $oFilter;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function GetDisabledUsers(bool $bAllData = true): null|DBObjectSearch|DBUnionSearch
|
||||
{
|
||||
$sOQLDisabledUser = "
|
||||
SELECT User AS u
|
||||
WHERE u.status = 'disabled'
|
||||
";
|
||||
try {
|
||||
$oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOQLDisabledUser) : DBObjectSearch::FromOQL($sOQLDisabledUser);
|
||||
}
|
||||
catch (Exception $e) {
|
||||
IssueLog::Error('Core:GetDisabledUsersQuota:Error : '.$e->getMessage());
|
||||
throw new Exception(Dict::Format('Core:GetQuota:Error', Dict::S('Core:DisabledUsers')));
|
||||
}
|
||||
|
||||
return $oFilter;
|
||||
}
|
||||
|
||||
private function IsUserReadOnly(User $oUser, string $sClassCategory)
|
||||
{
|
||||
UserRights::Login($oUser->GetName());
|
||||
|
||||
foreach (MetaModel::GetClasses($sClassCategory) as $sClass) {
|
||||
$aClassStimuli = MetaModel::EnumStimuli($sClass);
|
||||
if (count($aClassStimuli) > 0) {
|
||||
$aStimuli = [];
|
||||
foreach ($aClassStimuli as $sStimulusCode => $oStimulus) {
|
||||
if (UserRights::IsStimulusAllowed($sClass, $sStimulusCode, null, $oUser)) {
|
||||
$aStimuli[] =
|
||||
$oStimulus->GetLabel();
|
||||
}
|
||||
}
|
||||
$sStimuli = implode(', ', $aStimuli);
|
||||
} else {
|
||||
$sStimuli = '';
|
||||
}
|
||||
|
||||
if (
|
||||
UserRights::IsActionAllowed($sClass, UR_ACTION_MODIFY, null, $oUser) ||
|
||||
UserRights::IsActionAllowed($sClass, UR_ACTION_BULK_MODIFY, null, $oUser) ||
|
||||
UserRights::IsActionAllowed($sClass, UR_ACTION_DELETE, null, $oUser) ||
|
||||
UserRights::IsActionAllowed($sClass, UR_ACTION_BULK_DELETE, null, $oUser) ||
|
||||
$sStimuli != ''
|
||||
) {
|
||||
UserRights::Logoff();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
UserRights::Logoff();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DictExceptionMissingString
|
||||
* @throws CoreException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function GetReadOnlyUsers(): array
|
||||
{
|
||||
$aReadOnlyUsers = [];
|
||||
$oAllUsersFilter = $this->GetAllUsers();
|
||||
$aAllUsers = $this->GetUsersFromFilter($oAllUsersFilter);
|
||||
/** @var User $oUser */
|
||||
foreach ($aAllUsers as $oUser) {
|
||||
$bIsReadOnlyUser = true;
|
||||
|
||||
if (!$this->IsUserReadOnly($oUser, 'bizmodel') ||
|
||||
!$this->IsUserReadOnly($oUser, 'grant_by_profile')) {
|
||||
$bIsReadOnlyUser = false;
|
||||
}
|
||||
|
||||
if ($bIsReadOnlyUser) {
|
||||
$aReadOnlyUsers[] = $oUser;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TODO remove disabled users
|
||||
return $aReadOnlyUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getPortalUsers(bool $bAllData = true): null|DBObjectSearch|DBUnionSearch
|
||||
{
|
||||
$sOQLPortalUser = '
|
||||
SELECT User AS u
|
||||
JOIN URP_UserProfile AS uup ON uup.userid = u.id
|
||||
JOIN URP_Profiles AS up ON uup.profileid = up.id
|
||||
WHERE up.name = \' '.PORTAL_PROFILE_NAME.'\'';
|
||||
|
||||
try {
|
||||
$oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOQLPortalUser) : DBObjectSearch::FromOQL($sOQLPortalUser);
|
||||
}
|
||||
catch (Exception $e) {
|
||||
IssueLog::Error('combodo-users-quota-slave/GetUsersInQuota : '.$e->getMessage(), 'combodo-users-quota');
|
||||
throw new Exception(Dict::Format('Core:GetQuota:Error', Dict::S('Core:PortalUsers')));
|
||||
}
|
||||
|
||||
// TODO remove read only users
|
||||
return $oFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CoreUnexpectedValue
|
||||
* @throws CoreException
|
||||
* @throws MySQLException
|
||||
*/
|
||||
public function GetUsersFromFilter(DBObjectSearch|DBUnionSearch|null $oFilter, array $aOrderBy = [], array $aArgs = []): array
|
||||
{
|
||||
$aUsers = [];
|
||||
if (is_null($oFilter)) {
|
||||
return $aUsers;
|
||||
}
|
||||
$oSet = new DBObjectSet($oFilter, $aOrderBy, $aArgs);
|
||||
while ($oUser = $oSet->fetch()) {
|
||||
$aUsers[] = $oUser;
|
||||
}
|
||||
|
||||
return $aUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function GetAllUsers(bool $bAllData = true): DBUnionSearch|DBObjectSearch|DBSearch|null
|
||||
{
|
||||
$sOqlUser = 'SELECT User';
|
||||
|
||||
try {
|
||||
$oFilter = $bAllData ? DBObjectSearch::FromOQL_AllData($sOqlUser) : DBObjectSearch::FromOQL($sOqlUser);
|
||||
}
|
||||
catch (Exception $e) {
|
||||
IssueLog::Error('combodo-users-quota-slave/GetUsersNotInQuota : '.$e->getMessage(), 'combodo-users-quota');
|
||||
throw new Exception(Dict::S('CombodoUserQuota:Error'));
|
||||
}
|
||||
|
||||
return $oFilter;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace Users;
|
||||
|
||||
use CMDBObjectSet;
|
||||
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
|
||||
use Combodo\iTop\Users\ITopUserQuotaRepository;
|
||||
use DBObjectSearch;
|
||||
use MetaModel;
|
||||
use User;
|
||||
|
||||
class ITopUserQuotaRepositoryTest extends ItopDataTestCase{
|
||||
|
||||
private static bool $bDatasetInitialized = false;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
if (self::$bDatasetInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->createUsersQuotaDataset();
|
||||
self::$bDatasetInitialized = true;
|
||||
}
|
||||
|
||||
|
||||
/** * Creates a deterministic dataset for quota tests. * Users are created only once (idempotent on login). */
|
||||
private function createUsersQuotaDataset(): void
|
||||
{
|
||||
// Keep names unique and easy to clean up later if needed.
|
||||
$sPrefix = 'quota_test_';
|
||||
|
||||
// Create one user per quota "kind".
|
||||
// NOTE: profile names can vary by iTop distribution; we try common ones.
|
||||
$this->createUserIfMissing($sPrefix.'console', true, ['Administrator', 'Configuration Administrator']);
|
||||
$this->createUserIfMissing($sPrefix.'portal', true, ['Portal user', 'Portal User']);
|
||||
$this->createUserIfMissing($sPrefix.'readonly', true, ['ReadOnlyCI']);
|
||||
$this->createUserIfMissing($sPrefix.'application', true, ['Service Desk Agent', 'Change Manager', 'Administrator']);
|
||||
$this->createUserIfMissing($sPrefix.'disabled', false, ['Service Desk Agent', 'Administrator']);
|
||||
$this->createUserIfMissing($sPrefix.'disabled', false, ['Service Desk Agent', 'Administrator']);
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
private function createUserIfMissing(string $sLogin, bool $bEnabled, array $aCandidateProfileNames): void
|
||||
{
|
||||
if ($this->findUserByLogin($sLogin) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$iProfileId = $this->findFirstProfileIdByNames($aCandidateProfileNames);
|
||||
$this->assertNotNull(
|
||||
$iProfileId,
|
||||
sprintf('Could not find any profile among: %s', implode(', ', $aCandidateProfileNames))
|
||||
);
|
||||
|
||||
$oOrg = MetaModel::NewObject('Organization');
|
||||
$oOrg->Set('name', 'Quota Test Org');
|
||||
$oOrg->DBInsert();
|
||||
|
||||
$oPerson = MetaModel::NewObject('Person');
|
||||
$oPerson->Set('name', strtoupper($sLogin));
|
||||
$oPerson->Set('first_name', 'Quota');
|
||||
$oPerson->Set('org_id', $oOrg->GetKey());
|
||||
$oPerson->Set('email', $sLogin.'@example.invalid');
|
||||
$oPerson->DBInsert();
|
||||
|
||||
$oUser = MetaModel::NewObject('UserLocal');
|
||||
$oUser->Set('login', $sLogin);
|
||||
$oUser->Set('password', 'QuotaTest#123');
|
||||
$oUser->Set('contactid', $oPerson->GetKey());
|
||||
$oUser->Set('status', $bEnabled ? 'enabled' : 'disabled');
|
||||
|
||||
$oProfileList = $oUser->Get('profile_list');
|
||||
$oLink = MetaModel::NewObject('URP_UserProfile');
|
||||
$oLink->Set('profileid', $iProfileId);
|
||||
$oProfileList->AddItem($oLink);
|
||||
$oUser->Set('profile_list', $oProfileList);
|
||||
|
||||
$oUser->DBInsert();
|
||||
}
|
||||
|
||||
private function findFirstProfileIdByNames(array $aProfileNames): ?int
|
||||
{
|
||||
foreach ($aProfileNames as $sProfileName) {
|
||||
$oSearch = DBObjectSearch::FromOQL('SELECT URP_Profiles WHERE name = :name');
|
||||
$oSet = new CMDBObjectSet($oSearch, [], ['name' => $sProfileName]);
|
||||
$oProfile = $oSet->Fetch();
|
||||
if ($oProfile !== false && $oProfile !== null) {
|
||||
return (int) $oProfile->GetKey();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function findUserByLogin(string $sLogin): ?User
|
||||
{
|
||||
$oSearch = DBObjectSearch::FromOQL('SELECT User WHERE login = :login');
|
||||
$oSet = new CMDBObjectSet($oSearch, [], ['login' => $sLogin]);
|
||||
$oUser = $oSet->Fetch();
|
||||
return ($oUser instanceof User) ? $oUser : null;
|
||||
}
|
||||
|
||||
|
||||
public function testNotDuplicateInDifferentQuotas(): void
|
||||
{
|
||||
$oITopUserRepository = new ITopUserQuotaRepository();
|
||||
|
||||
$aQuotaUsers = [
|
||||
'console' => $oITopUserRepository->GetUsersFromFilter($oITopUserRepository->GetConsoleUsers()),
|
||||
'portal' => $oITopUserRepository->GetUsersFromFilter($oITopUserRepository->GetPortalUsers()),
|
||||
'disabled' => $oITopUserRepository->GetUsersFromFilter($oITopUserRepository->GetDisabledUsers()),
|
||||
'readonly' => $oITopUserRepository->GetReadOnlyUsers(),
|
||||
'application' => $oITopUserRepository->GetUsersFromFilter($oITopUserRepository->GetApplicationUsers()),
|
||||
];
|
||||
|
||||
$aUserToQuotas = [];
|
||||
foreach ($aQuotaUsers as $sQuota => $aUsers) {
|
||||
foreach ($aUsers as $oUser) {
|
||||
$sUserId = (string) $oUser->GetKey();
|
||||
$aUserToQuotas[$sUserId][$sQuota] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$aDuplicates = [];
|
||||
foreach ($aUserToQuotas as $sUserId => $aQuotas) {
|
||||
$aQuotaNames = array_keys($aQuotas);
|
||||
if (count($aQuotaNames) > 1) {
|
||||
sort($aQuotaNames);
|
||||
$aDuplicates[] = sprintf('User #%s appears in: %s', $sUserId, implode(', ', $aQuotaNames));
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertEmpty(
|
||||
$aDuplicates,
|
||||
"Some users are counted in multiple quotas:\n- ".implode("\n- ", $aDuplicates)
|
||||
);
|
||||
}
|
||||
|
||||
public function testAllUsersAreInQuota () {
|
||||
$oITopUserRepository = new ITopUserQuotaRepository();
|
||||
|
||||
$oConsoleUsersFilter = $oITopUserRepository->GetConsoleUsers();
|
||||
$aConsoleUsers = $oITopUserRepository->GetUsersFromFilter($oConsoleUsersFilter);
|
||||
$oPortalUsersFilter = $oITopUserRepository->GetPortalUsers();
|
||||
$aPortalUsers = $oITopUserRepository->GetUsersFromFilter($oPortalUsersFilter);
|
||||
$oDisabledUsersFilter = $oITopUserRepository->GetDisabledUsers();
|
||||
$aDisabledUsers = $oITopUserRepository->GetUsersFromFilter($oDisabledUsersFilter);
|
||||
$aReadOnlyUsers = $oITopUserRepository->GetReadOnlyUsers();
|
||||
$oApplicationUsersFilter = $oITopUserRepository->GetApplicationUsers();
|
||||
$aApplicationUsers = $oITopUserRepository->GetUsersFromFilter($oApplicationUsersFilter);
|
||||
|
||||
$aAllUsersFromQuota = array_merge($aConsoleUsers, $aPortalUsers, $aDisabledUsers, $aReadOnlyUsers, $aApplicationUsers);
|
||||
|
||||
$oAllUsersFilter = $oITopUserRepository->GetAllUsers();
|
||||
$aAllUsersFromOQL = $oITopUserRepository->GetUsersFromFilter($oAllUsersFilter);
|
||||
|
||||
$this->assertEmpty(array_merge(array_diff($aAllUsersFromQuota, $aAllUsersFromOQL), array_diff($aAllUsersFromOQL, $aAllUsersFromQuota)));
|
||||
}
|
||||
|
||||
public function testAllUsersInQuotaAreUsersObjects ()
|
||||
{
|
||||
$oITopUserRepository = new ITopUserQuotaRepository();
|
||||
|
||||
$oConsoleUsersFilter = $oITopUserRepository->GetConsoleUsers();
|
||||
$aConsoleUsers = $oITopUserRepository->GetUsersFromFilter($oConsoleUsersFilter);
|
||||
$oPortalUsersFilter = $oITopUserRepository->GetPortalUsers();
|
||||
$aPortalUsers = $oITopUserRepository->GetUsersFromFilter($oPortalUsersFilter);
|
||||
$oDisabledUsersFilter = $oITopUserRepository->GetDisabledUsers();
|
||||
$aDisabledUsers = $oITopUserRepository->GetUsersFromFilter($oDisabledUsersFilter);
|
||||
$aReadOnlyUsers = $oITopUserRepository->GetReadOnlyUsers();
|
||||
$oApplicationUsersFilter = $oITopUserRepository->GetApplicationUsers();
|
||||
$aApplicationUsers = $oITopUserRepository->GetUsersFromFilter($oApplicationUsersFilter);
|
||||
|
||||
$aAllQuotaUsers = array_merge($aConsoleUsers, $aPortalUsers, $aDisabledUsers, $aReadOnlyUsers, $aApplicationUsers);
|
||||
|
||||
foreach ($aAllQuotaUsers as $oUser) {
|
||||
$this->assertInstanceOf(User::class, $oUser);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user