mirror of
https://github.com/datahub-project/datahub.git
synced 2025-12-24 16:38:19 +00:00
feat(access): Improve external role retrieval (#10160)
Co-authored-by: Chris Collins <chriscollins3456@gmail.com>
This commit is contained in:
parent
98c0bf6499
commit
2624e1970c
@ -130,6 +130,7 @@ import com.linkedin.datahub.graphql.resolvers.dataproduct.ListDataProductAssetsR
|
||||
import com.linkedin.datahub.graphql.resolvers.dataproduct.UpdateDataProductResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.dataset.DatasetStatsSummaryResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.dataset.DatasetUsageStatsResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.dataset.IsAssignedToMeResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.deprecation.UpdateDeprecationResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.domain.CreateDomainResolver;
|
||||
import com.linkedin.datahub.graphql.resolvers.domain.DeleteDomainResolver;
|
||||
@ -389,7 +390,7 @@ import org.dataloader.DataLoader;
|
||||
import org.dataloader.DataLoaderOptions;
|
||||
|
||||
/**
|
||||
* A {@link GraphQLEngine} configured to provide access to the entities and aspects on the the GMS
|
||||
* A {@link GraphQLEngine} configured to provide access to the entities and aspects on the GMS
|
||||
* graph.
|
||||
*/
|
||||
@Slf4j
|
||||
@ -716,7 +717,7 @@ public class GmsGraphQLEngine {
|
||||
configureVersionedDatasetResolvers(builder);
|
||||
configureAccessAccessTokenMetadataResolvers(builder);
|
||||
configureTestResultResolvers(builder);
|
||||
configureRoleResolvers(builder);
|
||||
configureDataHubRoleResolvers(builder);
|
||||
configureSchemaFieldResolvers(builder);
|
||||
configureERModelRelationshipResolvers(builder);
|
||||
configureEntityPathResolvers(builder);
|
||||
@ -729,6 +730,7 @@ public class GmsGraphQLEngine {
|
||||
configureFormResolvers(builder);
|
||||
configureIncidentResolvers(builder);
|
||||
configureRestrictedResolvers(builder);
|
||||
configureRoleResolvers(builder);
|
||||
}
|
||||
|
||||
private void configureOrganisationRoleResolvers(RuntimeWiring.Builder builder) {
|
||||
@ -2690,7 +2692,7 @@ public class GmsGraphQLEngine {
|
||||
})));
|
||||
}
|
||||
|
||||
private void configureRoleResolvers(final RuntimeWiring.Builder builder) {
|
||||
private void configureDataHubRoleResolvers(final RuntimeWiring.Builder builder) {
|
||||
builder.type(
|
||||
"DataHubRole",
|
||||
typeWiring ->
|
||||
@ -2925,4 +2927,10 @@ public class GmsGraphQLEngine {
|
||||
siblingGraphService, restrictedService, this.authorizationConfiguration))
|
||||
.dataFetcher("relationships", new EntityRelationshipsResultResolver(graphClient)));
|
||||
}
|
||||
|
||||
private void configureRoleResolvers(final RuntimeWiring.Builder builder) {
|
||||
builder.type(
|
||||
"Role",
|
||||
typeWiring -> typeWiring.dataFetcher("isAssignedToMe", new IsAssignedToMeResolver()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
package com.linkedin.datahub.graphql.resolvers.dataset;
|
||||
|
||||
import com.linkedin.datahub.graphql.QueryContext;
|
||||
import com.linkedin.datahub.graphql.generated.CorpUser;
|
||||
import com.linkedin.datahub.graphql.generated.Role;
|
||||
import com.linkedin.datahub.graphql.generated.RoleUser;
|
||||
import graphql.schema.DataFetcher;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class IsAssignedToMeResolver implements DataFetcher<CompletableFuture<Boolean>> {
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Boolean> get(final DataFetchingEnvironment environment)
|
||||
throws Exception {
|
||||
final QueryContext context = environment.getContext();
|
||||
final Role role = environment.getSource();
|
||||
return CompletableFuture.supplyAsync(
|
||||
() -> {
|
||||
try {
|
||||
final Set<String> assignedUserUrns =
|
||||
role.getActors() != null && role.getActors().getUsers() != null
|
||||
? role.getActors().getUsers().stream()
|
||||
.map(RoleUser::getUser)
|
||||
.map(CorpUser::getUrn)
|
||||
.collect(Collectors.toSet())
|
||||
: Collections.emptySet();
|
||||
return assignedUserUrns.contains(context.getActorUrn());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(
|
||||
"Failed to determine if current user is assigned to Role", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1720,6 +1720,7 @@ type Role implements Entity {
|
||||
"""
|
||||
actors: Actor
|
||||
|
||||
isAssignedToMe: Boolean!
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
package com.linkedin.datahub.graphql.resolvers.role;
|
||||
|
||||
import static com.linkedin.datahub.graphql.TestUtils.getMockAllowContext;
|
||||
import static org.testng.Assert.assertFalse;
|
||||
import static org.testng.Assert.assertTrue;
|
||||
|
||||
import com.linkedin.common.urn.Urn;
|
||||
import com.linkedin.common.urn.UrnUtils;
|
||||
import com.linkedin.datahub.graphql.QueryContext;
|
||||
import com.linkedin.datahub.graphql.generated.*;
|
||||
import com.linkedin.datahub.graphql.resolvers.dataset.IsAssignedToMeResolver;
|
||||
import graphql.schema.DataFetchingEnvironment;
|
||||
import java.util.ArrayList;
|
||||
import org.mockito.Mockito;
|
||||
import org.testng.annotations.Test;
|
||||
|
||||
public class IsAssignedToMeResolverTest {
|
||||
|
||||
private static final Urn TEST_CORP_USER_URN_1 = UrnUtils.getUrn("urn:li:corpuser:test-user-1");
|
||||
private static final Urn TEST_CORP_USER_URN_2 = UrnUtils.getUrn("urn:li:corpuser:test-user-2");
|
||||
private static final Urn TEST_CORP_USER_URN_3 = UrnUtils.getUrn("urn:li:corpuser:test-user-3");
|
||||
|
||||
@Test
|
||||
public void testReturnsTrueIfCurrentUserIsAssignedToRole() throws Exception {
|
||||
|
||||
CorpUser corpUser1 = new CorpUser();
|
||||
corpUser1.setUrn(TEST_CORP_USER_URN_1.toString());
|
||||
CorpUser corpUser2 = new CorpUser();
|
||||
corpUser2.setUrn(TEST_CORP_USER_URN_2.toString());
|
||||
CorpUser corpUser3 = new CorpUser();
|
||||
corpUser3.setUrn(TEST_CORP_USER_URN_3.toString());
|
||||
|
||||
ArrayList<RoleUser> roleUsers = new ArrayList<>();
|
||||
roleUsers.add(new RoleUser(corpUser1));
|
||||
roleUsers.add(new RoleUser(corpUser2));
|
||||
roleUsers.add(new RoleUser(corpUser3));
|
||||
|
||||
Actor actor = new Actor();
|
||||
actor.setUsers(roleUsers);
|
||||
Role role = new Role();
|
||||
role.setUrn("urn:li:role:fake-role");
|
||||
role.setActors(actor);
|
||||
|
||||
QueryContext mockContext = getMockAllowContext(TEST_CORP_USER_URN_1.toString());
|
||||
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
|
||||
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
|
||||
Mockito.when(mockEnv.getSource()).thenReturn(role);
|
||||
|
||||
IsAssignedToMeResolver resolver = new IsAssignedToMeResolver();
|
||||
assertTrue(resolver.get(mockEnv).get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReturnsFalseIfCurrentUserIsNotAssignedToRole() throws Exception {
|
||||
|
||||
CorpUser corpUser1 = new CorpUser();
|
||||
corpUser1.setUrn(TEST_CORP_USER_URN_1.toString());
|
||||
CorpUser corpUser2 = new CorpUser();
|
||||
corpUser2.setUrn(TEST_CORP_USER_URN_2.toString());
|
||||
CorpUser corpUser3 = new CorpUser();
|
||||
corpUser3.setUrn(TEST_CORP_USER_URN_3.toString());
|
||||
|
||||
ArrayList<RoleUser> roleUsers = new ArrayList<>();
|
||||
roleUsers.add(new RoleUser(corpUser2));
|
||||
roleUsers.add(new RoleUser(corpUser3));
|
||||
|
||||
Actor actor = new Actor();
|
||||
actor.setUsers(roleUsers);
|
||||
Role role = new Role();
|
||||
role.setUrn("urn:li:role:fake-role");
|
||||
role.setActors(actor);
|
||||
|
||||
QueryContext mockContext = getMockAllowContext(TEST_CORP_USER_URN_1.toString());
|
||||
DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class);
|
||||
Mockito.when(mockEnv.getContext()).thenReturn(mockContext);
|
||||
Mockito.when(mockEnv.getSource()).thenReturn(role);
|
||||
|
||||
IsAssignedToMeResolver resolver = new IsAssignedToMeResolver();
|
||||
assertFalse(resolver.get(mockEnv).get());
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,6 @@ import { SpinProps } from 'antd/es/spin';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { useBaseEntity } from '../../../EntityContext';
|
||||
import { GetDatasetQuery, useGetExternalRolesQuery } from '../../../../../../graphql/dataset.generated';
|
||||
import { useGetMeQuery } from '../../../../../../graphql/me.generated';
|
||||
import { handleAccessRoles } from './utils';
|
||||
import AccessManagerDescription from './AccessManagerDescription';
|
||||
|
||||
@ -59,10 +58,10 @@ const AccessButton = styled(Button)`
|
||||
`;
|
||||
|
||||
export default function AccessManagement() {
|
||||
const { data: loggedInUser } = useGetMeQuery({ fetchPolicy: 'cache-first' });
|
||||
const baseEntity = useBaseEntity<GetDatasetQuery>();
|
||||
|
||||
const { data: externalRoles, loading: isLoading } = useGetExternalRolesQuery({
|
||||
variables: { urn: baseEntity?.dataset?.urn as string },
|
||||
variables: { urn: baseEntity?.dataset?.urn as string, },
|
||||
skip: !baseEntity?.dataset?.urn,
|
||||
});
|
||||
|
||||
@ -114,7 +113,7 @@ export default function AccessManagement() {
|
||||
return (
|
||||
<StyledTable
|
||||
loading={isLoading ? spinProps : false}
|
||||
dataSource={handleAccessRoles(externalRoles, loggedInUser)}
|
||||
dataSource={handleAccessRoles(externalRoles)}
|
||||
columns={columns} pagination={false}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -1,27 +1,38 @@
|
||||
import { handleAccessRoles } from '../utils';
|
||||
import { GetExternalRolesQuery } from '../../../../../../../graphql/dataset.generated';
|
||||
import { GetMeQuery } from '../../../../../../../graphql/me.generated';
|
||||
|
||||
describe('handleAccessRoles', () => {
|
||||
it('should properly map the externalroles and loggedin user', () => {
|
||||
it('should properly map the externalroles', () => {
|
||||
const externalRolesQuery: GetExternalRolesQuery = {
|
||||
dataset: {
|
||||
access: {
|
||||
roles: [
|
||||
{
|
||||
role: {
|
||||
id: 'accessRole',
|
||||
id: 'test-role-1',
|
||||
properties: {
|
||||
name: 'accessRole',
|
||||
name: 'Test Role 1',
|
||||
description:
|
||||
'This role access is required by the developers to test and deploy the code also adding few more details to check the description length for the given data and hence check the condition of read more and read less ',
|
||||
type: 'READ',
|
||||
requestUrl: 'https://www.google.com/',
|
||||
requestUrl: 'https://www.google.com/role-1',
|
||||
},
|
||||
urn: 'urn:li:role:accessRole',
|
||||
actors: {
|
||||
users: null,
|
||||
urn: 'urn:li:role:test-role-1',
|
||||
isAssignedToMe: true
|
||||
},
|
||||
},
|
||||
{
|
||||
role: {
|
||||
id: 'test-role-2',
|
||||
properties: {
|
||||
name: 'Test Role 2',
|
||||
description:
|
||||
'This role access is required by the developers to test and deploy the code also adding few more details to check the description length for the given data and hence check the condition of read more and read less ',
|
||||
type: 'READ',
|
||||
requestUrl: 'https://www.google.com/role-2',
|
||||
},
|
||||
urn: 'urn:li:role:test-role-2',
|
||||
isAssignedToMe: false
|
||||
},
|
||||
},
|
||||
],
|
||||
@ -30,72 +41,23 @@ describe('handleAccessRoles', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const GetMeQueryUser: GetMeQuery = {
|
||||
me: {
|
||||
corpUser: {
|
||||
urn: 'urn:li:corpuser:datahub',
|
||||
username: 'datahub',
|
||||
info: {
|
||||
active: true,
|
||||
displayName: 'DataHub',
|
||||
title: 'DataHub Root User',
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
fullName: null,
|
||||
email: null,
|
||||
__typename: 'CorpUserInfo',
|
||||
},
|
||||
editableProperties: {
|
||||
displayName: null,
|
||||
title: null,
|
||||
pictureLink:
|
||||
'https://raw.githubusercontent.com/datahub-project/datahub/master/datahub-web-react/src/images/default_avatar.png',
|
||||
teams: [],
|
||||
skills: [],
|
||||
__typename: 'CorpUserEditableProperties',
|
||||
},
|
||||
settings: {
|
||||
appearance: {
|
||||
showSimplifiedHomepage: false,
|
||||
__typename: 'CorpUserAppearanceSettings',
|
||||
},
|
||||
views: null,
|
||||
__typename: 'CorpUserSettings',
|
||||
},
|
||||
__typename: 'CorpUser',
|
||||
},
|
||||
platformPrivileges: {
|
||||
viewAnalytics: true,
|
||||
managePolicies: true,
|
||||
manageIdentities: true,
|
||||
generatePersonalAccessTokens: true,
|
||||
manageIngestion: true,
|
||||
manageSecrets: true,
|
||||
manageDomains: true,
|
||||
manageTests: true,
|
||||
manageGlossaries: true,
|
||||
manageUserCredentials: true,
|
||||
manageTags: true,
|
||||
createDomains: true,
|
||||
createTags: true,
|
||||
manageGlobalViews: true,
|
||||
manageOwnershipTypes: true,
|
||||
manageGlobalAnnouncements: true,
|
||||
manageTokens: true,
|
||||
__typename: 'PlatformPrivileges',
|
||||
},
|
||||
__typename: 'AuthenticatedUser',
|
||||
},
|
||||
};
|
||||
const externalRole = handleAccessRoles(externalRolesQuery, GetMeQueryUser);
|
||||
const externalRole = handleAccessRoles(externalRolesQuery);
|
||||
expect(externalRole).toMatchObject([
|
||||
{
|
||||
name: 'accessRole',
|
||||
name: 'Test Role 1',
|
||||
description:
|
||||
'This role access is required by the developers to test and deploy the code also adding few more details to check the description length for the given data and hence check the condition of read more and read less ',
|
||||
accessType: 'READ',
|
||||
hasAccess: true,
|
||||
url: 'https://www.google.com/role-1',
|
||||
},
|
||||
{
|
||||
name: 'Test Role 2',
|
||||
description:
|
||||
'This role access is required by the developers to test and deploy the code also adding few more details to check the description length for the given data and hence check the condition of read more and read less ',
|
||||
accessType: 'READ',
|
||||
hasAccess: false,
|
||||
url: 'https://www.google.com/',
|
||||
url: 'https://www.google.com/role-2',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -107,167 +69,7 @@ describe('handleAccessRoles', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const GetMeQueryUser: GetMeQuery = {
|
||||
me: {
|
||||
corpUser: {
|
||||
urn: 'urn:li:corpuser:datahub',
|
||||
username: 'datahub',
|
||||
info: {
|
||||
active: true,
|
||||
displayName: 'DataHub',
|
||||
title: 'DataHub Root User',
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
fullName: null,
|
||||
email: null,
|
||||
__typename: 'CorpUserInfo',
|
||||
},
|
||||
editableProperties: {
|
||||
displayName: null,
|
||||
title: null,
|
||||
pictureLink:
|
||||
'https://raw.githubusercontent.com/datahub-project/datahub/master/datahub-web-react/src/images/default_avatar.png',
|
||||
teams: [],
|
||||
skills: [],
|
||||
__typename: 'CorpUserEditableProperties',
|
||||
},
|
||||
settings: {
|
||||
appearance: {
|
||||
showSimplifiedHomepage: false,
|
||||
__typename: 'CorpUserAppearanceSettings',
|
||||
},
|
||||
views: null,
|
||||
__typename: 'CorpUserSettings',
|
||||
},
|
||||
__typename: 'CorpUser',
|
||||
},
|
||||
platformPrivileges: {
|
||||
viewAnalytics: true,
|
||||
managePolicies: true,
|
||||
manageIdentities: true,
|
||||
generatePersonalAccessTokens: true,
|
||||
manageIngestion: true,
|
||||
manageSecrets: true,
|
||||
manageDomains: true,
|
||||
manageTests: true,
|
||||
manageGlossaries: true,
|
||||
manageUserCredentials: true,
|
||||
manageTags: true,
|
||||
createDomains: true,
|
||||
createTags: true,
|
||||
manageGlobalViews: true,
|
||||
manageOwnershipTypes: true,
|
||||
manageGlobalAnnouncements: true,
|
||||
manageTokens: true,
|
||||
__typename: 'PlatformPrivileges',
|
||||
},
|
||||
__typename: 'AuthenticatedUser',
|
||||
},
|
||||
};
|
||||
const externalRole = handleAccessRoles(externalRolesQuery, GetMeQueryUser);
|
||||
const externalRole = handleAccessRoles(externalRolesQuery);
|
||||
expect(externalRole).toMatchObject([]);
|
||||
});
|
||||
it('should properly map the externalroles and loggedin user and access true', () => {
|
||||
const externalRolesQuery: GetExternalRolesQuery = {
|
||||
dataset: {
|
||||
access: {
|
||||
roles: [
|
||||
{
|
||||
role: {
|
||||
id: 'accessRole',
|
||||
properties: {
|
||||
name: 'accessRole',
|
||||
description:
|
||||
'This role access is required by the developers to test and deploy the code also adding few more details to check the description length for the given data and hence check the condition of read more and read less ',
|
||||
type: 'READ',
|
||||
requestUrl: 'https://www.google.com/',
|
||||
},
|
||||
urn: 'urn:li:role:accessRole',
|
||||
actors: {
|
||||
users: [
|
||||
{
|
||||
user: {
|
||||
urn: 'urn:li:corpuser:datahub',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
__typename: 'Dataset',
|
||||
},
|
||||
};
|
||||
|
||||
const GetMeQueryUser: GetMeQuery = {
|
||||
me: {
|
||||
corpUser: {
|
||||
urn: 'urn:li:corpuser:datahub',
|
||||
username: 'datahub',
|
||||
info: {
|
||||
active: true,
|
||||
displayName: 'DataHub',
|
||||
title: 'DataHub Root User',
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
fullName: null,
|
||||
email: null,
|
||||
__typename: 'CorpUserInfo',
|
||||
},
|
||||
editableProperties: {
|
||||
displayName: null,
|
||||
title: null,
|
||||
pictureLink:
|
||||
'https://raw.githubusercontent.com/datahub-project/datahub/master/datahub-web-react/src/images/default_avatar.png',
|
||||
teams: [],
|
||||
skills: [],
|
||||
__typename: 'CorpUserEditableProperties',
|
||||
},
|
||||
settings: {
|
||||
appearance: {
|
||||
showSimplifiedHomepage: false,
|
||||
__typename: 'CorpUserAppearanceSettings',
|
||||
},
|
||||
views: null,
|
||||
__typename: 'CorpUserSettings',
|
||||
},
|
||||
__typename: 'CorpUser',
|
||||
},
|
||||
platformPrivileges: {
|
||||
viewAnalytics: true,
|
||||
managePolicies: true,
|
||||
manageIdentities: true,
|
||||
generatePersonalAccessTokens: true,
|
||||
manageIngestion: true,
|
||||
manageSecrets: true,
|
||||
manageDomains: true,
|
||||
manageTests: true,
|
||||
manageGlossaries: true,
|
||||
manageUserCredentials: true,
|
||||
manageTags: true,
|
||||
createDomains: true,
|
||||
createTags: true,
|
||||
manageGlobalViews: true,
|
||||
manageOwnershipTypes: true,
|
||||
manageGlobalAnnouncements: true,
|
||||
manageTokens: true,
|
||||
__typename: 'PlatformPrivileges',
|
||||
},
|
||||
__typename: 'AuthenticatedUser',
|
||||
},
|
||||
};
|
||||
const externalRole = handleAccessRoles(externalRolesQuery, GetMeQueryUser);
|
||||
|
||||
expect(externalRole).toMatchObject([
|
||||
{
|
||||
name: 'accessRole',
|
||||
description:
|
||||
'This role access is required by the developers to test and deploy the code also adding few more details to check the description length for the given data and hence check the condition of read more and read less ',
|
||||
accessType: 'READ',
|
||||
hasAccess: true,
|
||||
url: 'https://www.google.com/',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export function handleAccessRoles(externalRoles, loggedInUser) {
|
||||
export function handleAccessRoles(externalRoles) {
|
||||
const accessRoles = new Array<any>();
|
||||
if (
|
||||
externalRoles?.dataset?.access &&
|
||||
@ -10,13 +10,7 @@ export function handleAccessRoles(externalRoles, loggedInUser) {
|
||||
name: userRoles?.role?.properties?.name || ' ',
|
||||
description: userRoles?.role?.properties?.description || ' ',
|
||||
accessType: userRoles?.role?.properties?.type || ' ',
|
||||
hasAccess:
|
||||
(userRoles?.role?.actors?.users &&
|
||||
userRoles?.role?.actors?.users.length > 0 &&
|
||||
userRoles?.role?.actors?.users?.some(
|
||||
(user) => user.user.urn === loggedInUser?.me?.corpUser.urn,
|
||||
)) ||
|
||||
false,
|
||||
hasAccess: userRoles?.role?.isAssignedToMe,
|
||||
url: userRoles?.role?.properties?.requestUrl || undefined,
|
||||
};
|
||||
accessRoles.push(role);
|
||||
|
||||
@ -330,13 +330,7 @@ fragment getRoles on Access {
|
||||
requestUrl
|
||||
}
|
||||
urn
|
||||
actors {
|
||||
users {
|
||||
user {
|
||||
urn
|
||||
}
|
||||
}
|
||||
}
|
||||
isAssignedToMe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user