[v14] Add missing alias and id to usergroup related api models (#16154)
* Added missing alias and Id to usergroup models create/update/response/item * Changed userGroup IsSystemGroup to more meaningfull fields Also enforced the AliasCanBeChanged businessrule 🙈 --------- Co-authored-by: Sven Geusens <sge@umbraco.dk> Co-authored-by: Mads Rasmussen <madsr@hey.com>
This commit is contained in:
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Security.Authorization.UserGroup;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.UserGroup;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
|
||||
@@ -29,11 +29,15 @@ public class UserGroupControllerBase : ManagementApiControllerBase
|
||||
.WithTitle("Duplicate alias")
|
||||
.WithDetail("A user group already exists with the attempted alias.")
|
||||
.Build()),
|
||||
UserGroupOperationStatus.CanNotUpdateAliasIsSystemUserGroup => BadRequest(problemDetailsBuilder
|
||||
.WithTitle("System user group")
|
||||
.WithDetail("Changing the alias is not allowed on a system user group.")
|
||||
.Build()),
|
||||
UserGroupOperationStatus.MissingUser => Unauthorized(problemDetailsBuilder
|
||||
.WithTitle("Missing user")
|
||||
.WithDetail("A performing user was not found when attempting the operation.")
|
||||
.Build()),
|
||||
UserGroupOperationStatus.IsSystemUserGroup => BadRequest(problemDetailsBuilder
|
||||
UserGroupOperationStatus.CanNotDeleteIsSystemUserGroup => BadRequest(problemDetailsBuilder
|
||||
.WithTitle("System user group")
|
||||
.WithDetail("The operation is not allowed on a system user group.")
|
||||
.Build()),
|
||||
|
||||
@@ -51,8 +51,9 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
|
||||
|
||||
return new UserGroupResponseModel
|
||||
{
|
||||
Name = userGroup.Name ?? string.Empty,
|
||||
Id = userGroup.Key,
|
||||
Name = userGroup.Name ?? string.Empty,
|
||||
Alias = userGroup.Alias,
|
||||
DocumentStartNode = ReferenceByIdModel.ReferenceOrNull(contentStartNodeKey),
|
||||
DocumentRootAccess = contentRootAccess,
|
||||
MediaStartNode = ReferenceByIdModel.ReferenceOrNull(mediaStartNodeKey),
|
||||
@@ -63,7 +64,8 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
|
||||
FallbackPermissions = userGroup.Permissions,
|
||||
Permissions = await _permissionPresentationFactory.CreateAsync(userGroup.GranularPermissions),
|
||||
Sections = userGroup.AllowedSections.Select(SectionMapper.GetName),
|
||||
IsSystemGroup = userGroup.IsSystemUserGroup()
|
||||
IsDeletable = !userGroup.IsSystemUserGroup(),
|
||||
AliasCanBeChanged = !userGroup.IsSystemUserGroup(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,8 +85,9 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
|
||||
|
||||
return new UserGroupResponseModel
|
||||
{
|
||||
Name = userGroup.Name ?? string.Empty,
|
||||
Id = userGroup.Key,
|
||||
Name = userGroup.Name ?? string.Empty,
|
||||
Alias = userGroup.Alias,
|
||||
DocumentStartNode = ReferenceByIdModel.ReferenceOrNull(contentStartNodeKey),
|
||||
MediaStartNode = ReferenceByIdModel.ReferenceOrNull(mediaStartNodeKey),
|
||||
Icon = userGroup.Icon,
|
||||
@@ -93,6 +96,8 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
|
||||
FallbackPermissions = userGroup.Permissions,
|
||||
Permissions = await _permissionPresentationFactory.CreateAsync(userGroup.GranularPermissions),
|
||||
Sections = userGroup.AllowedSections.Select(SectionMapper.GetName),
|
||||
IsDeletable = !userGroup.IsSystemUserGroup(),
|
||||
AliasCanBeChanged = !userGroup.IsSystemUserGroup(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,6 +112,7 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
|
||||
|
||||
return userGroupViewModels;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<UserGroupResponseModel>> CreateMultipleAsync(IEnumerable<IReadOnlyUserGroup> userGroups)
|
||||
{
|
||||
@@ -122,18 +128,21 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<IUserGroup, UserGroupOperationStatus>> CreateAsync(CreateUserGroupRequestModel requestModel)
|
||||
{
|
||||
var cleanedName = requestModel.Name.CleanForXss('[', ']', '(', ')', ':');
|
||||
|
||||
var group = new UserGroup(_shortStringHelper)
|
||||
{
|
||||
Name = cleanedName,
|
||||
Alias = cleanedName,
|
||||
Name = CleanUserGroupNameOrAliasForXss(requestModel.Name),
|
||||
Alias = CleanUserGroupNameOrAliasForXss(requestModel.Alias),
|
||||
Icon = requestModel.Icon,
|
||||
HasAccessToAllLanguages = requestModel.HasAccessToAllLanguages,
|
||||
Permissions = requestModel.FallbackPermissions,
|
||||
GranularPermissions = await _permissionPresentationFactory.CreatePermissionSetsAsync(requestModel.Permissions)
|
||||
GranularPermissions = await _permissionPresentationFactory.CreatePermissionSetsAsync(requestModel.Permissions),
|
||||
};
|
||||
|
||||
if (requestModel.Id.HasValue)
|
||||
{
|
||||
group.Key = requestModel.Id.Value;
|
||||
}
|
||||
|
||||
Attempt<UserGroupOperationStatus> assignmentAttempt = AssignStartNodesToUserGroup(requestModel, group);
|
||||
if (assignmentAttempt.Success is false)
|
||||
{
|
||||
@@ -186,7 +195,8 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
|
||||
current.AddAllowedSection(SectionMapper.GetAlias(sectionName));
|
||||
}
|
||||
|
||||
current.Name = request.Name.CleanForXss('[', ']', '(', ')', ':');
|
||||
current.Name = CleanUserGroupNameOrAliasForXss(request.Name);
|
||||
current.Alias = CleanUserGroupNameOrAliasForXss(request.Alias);
|
||||
current.Icon = request.Icon;
|
||||
current.HasAccessToAllLanguages = request.HasAccessToAllLanguages;
|
||||
|
||||
@@ -196,6 +206,9 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
|
||||
return Attempt.SucceedWithStatus(UserGroupOperationStatus.Success, current);
|
||||
}
|
||||
|
||||
private static string CleanUserGroupNameOrAliasForXss(string input)
|
||||
=> input.CleanForXss('[', ']', '(', ')', ':');
|
||||
|
||||
private async Task<Attempt<IEnumerable<string>, UserGroupOperationStatus>> MapLanguageIdsToIsoCodeAsync(IEnumerable<int> ids)
|
||||
{
|
||||
IEnumerable<ILanguage> languages = await _languageService.GetAllAsync();
|
||||
|
||||
@@ -112,6 +112,7 @@ public class ItemTypeMapDefinition : IMapDefinition
|
||||
target.Id = source.Key;
|
||||
target.Name = source.Name ?? source.Alias;
|
||||
target.Icon = source.Icon;
|
||||
target.Alias = source.Alias;
|
||||
}
|
||||
|
||||
// Umbraco.Code.MapAll
|
||||
|
||||
@@ -34131,6 +34131,7 @@
|
||||
},
|
||||
"CreateUserGroupRequestModel": {
|
||||
"required": [
|
||||
"alias",
|
||||
"documentRootAccess",
|
||||
"fallbackPermissions",
|
||||
"hasAccessToAllLanguages",
|
||||
@@ -34145,6 +34146,9 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"alias": {
|
||||
"type": "string"
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
@@ -34206,6 +34210,11 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -43105,6 +43114,7 @@
|
||||
},
|
||||
"UpdateUserGroupRequestModel": {
|
||||
"required": [
|
||||
"alias",
|
||||
"documentRootAccess",
|
||||
"fallbackPermissions",
|
||||
"hasAccessToAllLanguages",
|
||||
@@ -43119,6 +43129,9 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"alias": {
|
||||
"type": "string"
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
@@ -43432,12 +43445,17 @@
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"alias": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"UserGroupResponseModel": {
|
||||
"required": [
|
||||
"alias",
|
||||
"documentRootAccess",
|
||||
"fallbackPermissions",
|
||||
"hasAccessToAllLanguages",
|
||||
@@ -43454,6 +43472,9 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"alias": {
|
||||
"type": "string"
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
public class CreateUserGroupRequestModel : UserGroupBase
|
||||
{
|
||||
|
||||
public Guid? Id { get; set; }
|
||||
}
|
||||
|
||||
@@ -5,4 +5,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.UserGroup.Item;
|
||||
public class UserGroupItemResponseModel : NamedItemResponseModelBase
|
||||
{
|
||||
public string? Icon { get; set; }
|
||||
|
||||
public string? Alias { get; set; }
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ public class UserGroupBase
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The alias of the user groups
|
||||
/// </summary>
|
||||
public required string Alias { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The Icon for the user group
|
||||
/// </summary>
|
||||
|
||||
@@ -10,5 +10,10 @@ public class UserGroupResponseModel : UserGroupBase
|
||||
/// <summary>
|
||||
/// Whether this user group is required at system level (thus cannot be removed)
|
||||
/// </summary>
|
||||
public bool IsSystemGroup { get; set; }
|
||||
public bool IsDeletable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this user group is required at system level (thus alias needs to be fixed)
|
||||
/// </summary>
|
||||
public bool AliasCanBeChanged { get; set; }
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ public enum UserGroupOperationStatus
|
||||
AlreadyExists,
|
||||
DuplicateAlias,
|
||||
MissingUser,
|
||||
IsSystemUserGroup,
|
||||
CanNotDeleteIsSystemUserGroup,
|
||||
CanNotUpdateAliasIsSystemUserGroup,
|
||||
CancelledByNotification,
|
||||
MediaStartNodeKeyNotFound,
|
||||
DocumentStartNodeKeyNotFound,
|
||||
|
||||
@@ -263,7 +263,7 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
|
||||
|
||||
if (userGroup.IsSystemUserGroup())
|
||||
{
|
||||
return Attempt.Fail(UserGroupOperationStatus.IsSystemUserGroup);
|
||||
return Attempt.Fail(UserGroupOperationStatus.CanNotDeleteIsSystemUserGroup);
|
||||
}
|
||||
|
||||
return Attempt.Succeed(UserGroupOperationStatus.Success);
|
||||
@@ -520,12 +520,18 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
|
||||
return UserGroupOperationStatus.NotFound;
|
||||
}
|
||||
|
||||
IUserGroup? existing = _userGroupRepository.Get(userGroup.Alias);
|
||||
if (existing is not null && existing.Key != userGroup.Key)
|
||||
IUserGroup? existingByAlias = _userGroupRepository.Get(userGroup.Alias);
|
||||
if (existingByAlias is not null && existingByAlias.Key != userGroup.Key)
|
||||
{
|
||||
return UserGroupOperationStatus.DuplicateAlias;
|
||||
}
|
||||
|
||||
IUserGroup? existingByKey = await GetAsync(userGroup.Key);
|
||||
if (existingByKey is not null && existingByKey.IsSystemUserGroup() && existingByKey.Alias != userGroup.Alias)
|
||||
{
|
||||
return UserGroupOperationStatus.CanNotUpdateAliasIsSystemUserGroup;
|
||||
}
|
||||
|
||||
return UserGroupOperationStatus.Success;
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ public class UserGroupServiceValidationTests : UmbracoIntegrationTest
|
||||
var result = await UserGroupService.DeleteAsync(key);
|
||||
|
||||
Assert.IsFalse(result.Success);
|
||||
Assert.AreEqual(UserGroupOperationStatus.IsSystemUserGroup, result.Result);
|
||||
Assert.AreEqual(UserGroupOperationStatus.CanNotDeleteIsSystemUserGroup, result.Result);
|
||||
}
|
||||
|
||||
// these keys are not defined as "const" in Constants.Security but as "static readonly", so we have to hardcode
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
using System.Data;
|
||||
using System.Linq.Expressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services;
|
||||
@@ -21,7 +26,7 @@ public class UserGroupServiceTests
|
||||
public async Task Filter_Returns_Only_User_Groups_For_Non_Admin(params string[] userGroupAliases)
|
||||
{
|
||||
var userKey = Guid.NewGuid();
|
||||
var userGroupService = SetupUserGroupService(userKey, userGroupAliases);
|
||||
var userGroupService = SetupUserGroupServiceWithUserAndGetManyReturnsFourGroups(userKey, userGroupAliases);
|
||||
|
||||
var result = await userGroupService.FilterAsync(userKey, null, 0, 10);
|
||||
Assert.Multiple(() =>
|
||||
@@ -41,7 +46,7 @@ public class UserGroupServiceTests
|
||||
public async Task Filter_Does_Not_Return_Non_Existing_Groups(params string[] userGroupAliases)
|
||||
{
|
||||
var userKey = Guid.NewGuid();
|
||||
var userGroupService = SetupUserGroupService(userKey, userGroupAliases);
|
||||
var userGroupService = SetupUserGroupServiceWithUserAndGetManyReturnsFourGroups(userKey, userGroupAliases);
|
||||
|
||||
var result = await userGroupService.FilterAsync(userKey, null, 0, 10);
|
||||
Assert.Multiple(() =>
|
||||
@@ -55,7 +60,7 @@ public class UserGroupServiceTests
|
||||
public async Task Filter_Returns_All_Groups_For_Admin()
|
||||
{
|
||||
var userKey = Guid.NewGuid();
|
||||
var userGroupService = SetupUserGroupService(userKey, new [] { Constants.Security.AdminGroupAlias });
|
||||
var userGroupService = SetupUserGroupServiceWithUserAndGetManyReturnsFourGroups(userKey, new [] { Constants.Security.AdminGroupAlias });
|
||||
|
||||
var result = await userGroupService.FilterAsync(userKey, null, 0, 10);
|
||||
Assert.Multiple(() =>
|
||||
@@ -73,7 +78,7 @@ public class UserGroupServiceTests
|
||||
public async Task Filter_Can_Filter_By_Group_Name()
|
||||
{
|
||||
var userKey = Guid.NewGuid();
|
||||
var userGroupService = SetupUserGroupService(userKey, new [] { Constants.Security.AdminGroupAlias });
|
||||
var userGroupService = SetupUserGroupServiceWithUserAndGetManyReturnsFourGroups(userKey, new [] { Constants.Security.AdminGroupAlias });
|
||||
|
||||
var result = await userGroupService.FilterAsync(userKey, "e", 0, 10);
|
||||
Assert.Multiple(() =>
|
||||
@@ -85,6 +90,79 @@ public class UserGroupServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase(false,UserGroupOperationStatus.Success)]
|
||||
[TestCase(true,UserGroupOperationStatus.CanNotUpdateAliasIsSystemUserGroup)]
|
||||
public async Task Can_Not_Update_SystemGroup_Alias(bool isSystemGroup, UserGroupOperationStatus status)
|
||||
{
|
||||
// Arrange
|
||||
var actingUserKey = Guid.NewGuid();
|
||||
var mockUser = SetupUserWithGroupAccess(actingUserKey, [Constants.Security.AdminGroupAlias]);
|
||||
var userService = SetupUserServiceWithGetUserByKey(actingUserKey, mockUser);
|
||||
var userGroupRepository = new Mock<IUserGroupRepository>();
|
||||
var userGroupKey = Guid.NewGuid();
|
||||
var persistedUserGroup =
|
||||
new UserGroup(
|
||||
Mock.Of<IShortStringHelper>(),
|
||||
0,
|
||||
isSystemGroup ? Constants.Security.AdminGroupAlias : "someNonSystemAlias",
|
||||
"Administrators",
|
||||
null)
|
||||
{
|
||||
Id = 10,
|
||||
Key = userGroupKey,
|
||||
};
|
||||
userGroupRepository
|
||||
.Setup(r => r.Get(It.IsAny<IQuery<IUserGroup>>()))
|
||||
.Returns(new[]
|
||||
{
|
||||
persistedUserGroup
|
||||
});
|
||||
var updatingUserGroup = new UserGroup(Mock.Of<IShortStringHelper>(), 0, persistedUserGroup.Alias + "updated",
|
||||
persistedUserGroup.Name + "updated", null)
|
||||
{
|
||||
Key = persistedUserGroup.Key,
|
||||
Id = persistedUserGroup.Id
|
||||
};
|
||||
|
||||
var scopedNotificationPublisher = new Mock<IScopedNotificationPublisher>();
|
||||
scopedNotificationPublisher.Setup(p => p.PublishCancelableAsync(It.IsAny<ICancelableNotification>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var scope = new Mock<ICoreScope>();
|
||||
scope.SetupGet(s => s.Notifications).Returns(scopedNotificationPublisher.Object);
|
||||
|
||||
var query = new Mock<IQuery<IUserGroup>>();
|
||||
query.Setup(q => q.Where(It.IsAny<Expression<Func<IUserGroup, bool>>>())).Returns(query.Object);
|
||||
|
||||
var provider = new Mock<ICoreScopeProvider>();
|
||||
provider.Setup(p => p.CreateQuery<IUserGroup>()).Returns(query.Object);
|
||||
provider.Setup(p => p.CreateCoreScope(
|
||||
It.IsAny<IsolationLevel>(),
|
||||
It.IsAny<RepositoryCacheMode>(),
|
||||
It.IsAny<IEventDispatcher?>(),
|
||||
It.IsAny<IScopedNotificationPublisher?>(),
|
||||
It.IsAny<bool?>(),
|
||||
It.IsAny<bool>(),
|
||||
It.IsAny<bool>()))
|
||||
.Returns(scope.Object);
|
||||
|
||||
var service = new UserGroupService(
|
||||
provider.Object,
|
||||
Mock.Of<ILoggerFactory>(),
|
||||
Mock.Of<IEventMessagesFactory>(),
|
||||
userGroupRepository.Object,
|
||||
Mock.Of<IUserGroupPermissionService>(),
|
||||
Mock.Of<IEntityService>(),
|
||||
userService.Object,
|
||||
Mock.Of<ILogger<UserGroupService>>());
|
||||
|
||||
// act
|
||||
var updateAttempt = await service.UpdateAsync(updatingUserGroup, actingUserKey);
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(status, updateAttempt.Status);
|
||||
}
|
||||
|
||||
private IEnumerable<IReadOnlyUserGroup> CreateGroups(params string[] aliases)
|
||||
=> aliases.Select(alias =>
|
||||
{
|
||||
@@ -93,14 +171,11 @@ public class UserGroupServiceTests
|
||||
return group.Object;
|
||||
}).ToArray();
|
||||
|
||||
private IUserGroupService SetupUserGroupService(Guid userKey, string[] userGroupAliases)
|
||||
private IUserGroupService SetupUserGroupServiceWithUserAndGetManyReturnsFourGroups(Guid userKey, string[] userGroupAliases)
|
||||
{
|
||||
var user = new Mock<IUser>();
|
||||
user.SetupGet(u => u.Key).Returns(userKey);
|
||||
user.Setup(u => u.Groups).Returns(CreateGroups(userGroupAliases));
|
||||
var mockUser = SetupUserWithGroupAccess(userKey, userGroupAliases);
|
||||
|
||||
var userService = new Mock<IUserService>();
|
||||
userService.Setup(s => s.GetAsync(userKey)).Returns(Task.FromResult(user.Object));
|
||||
var userService = SetupUserServiceWithGetUserByKey(userKey, mockUser);
|
||||
|
||||
var userGroupRepository = new Mock<IUserGroupRepository>();
|
||||
userGroupRepository
|
||||
@@ -123,4 +198,19 @@ public class UserGroupServiceTests
|
||||
userService.Object,
|
||||
Mock.Of<ILogger<UserGroupService>>());
|
||||
}
|
||||
|
||||
private Mock<IUser> SetupUserWithGroupAccess(Guid userKey, string[] userGroupAliases)
|
||||
{
|
||||
var user = new Mock<IUser>();
|
||||
user.SetupGet(u => u.Key).Returns(userKey);
|
||||
user.Setup(u => u.Groups).Returns(CreateGroups(userGroupAliases));
|
||||
return user;
|
||||
}
|
||||
|
||||
private Mock<IUserService> SetupUserServiceWithGetUserByKey(Guid userKey, Mock<IUser> mockUser)
|
||||
{
|
||||
var userService = new Mock<IUserService>();
|
||||
userService.Setup(s => s.GetAsync(userKey)).Returns(Task.FromResult(mockUser.Object));
|
||||
return userService;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user