[permissions] Enable permissions V1 for all workspaces (#11172)
Closes https://github.com/twentyhq/core-team-issues/issues/526 (for reminder: 1. Make defaultRoleId non-nullable for an active workspace 2. Remove permissions V1 feature flag 3. Set member role as default role for new workspaces About 1.: An active workspace's defaultRoleId should never be null. We can't rely on a simple postgres NOT NULL constraint as defaultRoleId will always be initially null when the workspace is first created since the roles do not exist at that time. Let's add a more complex rule to ensure that About 3.: In the first phase of our deploy of permissions, we chose to assign admin role to all existing users, not to break any existing behavior with the introduction of the feature (= existing users have less rights than before). As we deploy permissions to all existing and future workspaces, let's set the member role as default role for future workspaces. )
This commit is contained in:
@ -87,17 +87,9 @@ export abstract class GraphqlQueryBaseResolverService<
|
||||
authContext.workspace.id,
|
||||
);
|
||||
|
||||
if (
|
||||
featureFlagsMap[FeatureFlagKey.IsPermissionsEnabled] &&
|
||||
objectMetadataItemWithFieldMaps.isSystem === true
|
||||
) {
|
||||
if (objectMetadataItemWithFieldMaps.isSystem === true) {
|
||||
await this.validateSystemObjectPermissionsOrThrow(options);
|
||||
}
|
||||
|
||||
if (
|
||||
featureFlagsMap[FeatureFlagKey.IsPermissionsEnabled] &&
|
||||
!objectMetadataItemWithFieldMaps.isSystem
|
||||
) {
|
||||
} else {
|
||||
await this.validateObjectRecordPermissionsOrThrow({
|
||||
operationName,
|
||||
options,
|
||||
|
||||
@ -17,7 +17,6 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
|
||||
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@ -140,15 +139,6 @@ export class BillingResolver {
|
||||
userWorkspaceId: string;
|
||||
isExecutedByApiKey: boolean;
|
||||
}) {
|
||||
const isPermissionsEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IsPermissionsEnabled,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isPermissionsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
await this.billingService.isSubscriptionIncompleteOnboardingStatus(
|
||||
workspaceId,
|
||||
|
||||
@ -9,9 +9,7 @@ type FeatureFlagMetadata = {
|
||||
export type PublicFeatureFlag = {
|
||||
key: Extract<
|
||||
FeatureFlagKey,
|
||||
| FeatureFlagKey.IsWorkflowEnabled
|
||||
| FeatureFlagKey.IsPermissionsEnabled
|
||||
| FeatureFlagKey.IsCustomDomainEnabled
|
||||
FeatureFlagKey.IsWorkflowEnabled | FeatureFlagKey.IsCustomDomainEnabled
|
||||
>;
|
||||
metadata: FeatureFlagMetadata;
|
||||
};
|
||||
@ -25,15 +23,6 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
|
||||
imagePath: 'https://twenty.com/images/lab/is-workflow-enabled.png',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: FeatureFlagKey.IsPermissionsEnabled,
|
||||
metadata: {
|
||||
label: 'Permissions V1',
|
||||
description:
|
||||
'Role-based access control system for workspace security management (Admin/Member)',
|
||||
imagePath: 'https://twenty.com/images/lab/is-permissions-enabled.png',
|
||||
},
|
||||
},
|
||||
...(process.env.CLOUDFLARE_API_KEY
|
||||
? [
|
||||
{
|
||||
|
||||
@ -12,7 +12,6 @@ export enum FeatureFlagKey {
|
||||
IsCustomDomainEnabled = 'IS_CUSTOM_DOMAIN_ENABLED',
|
||||
IsApprovedAccessDomainsEnabled = 'IS_APPROVED_ACCESS_DOMAINS_ENABLED',
|
||||
IsNewRelationEnabled = 'IS_NEW_RELATION_ENABLED',
|
||||
IsPermissionsEnabled = 'IS_PERMISSIONS_ENABLED',
|
||||
IsWorkflowFormActionEnabled = 'IS_WORKFLOW_FORM_ACTION_ENABLED',
|
||||
IsPermissionsV2Enabled = 'IS_PERMISSIONS_V2_ENABLED',
|
||||
}
|
||||
|
||||
@ -3,8 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import assert from 'assert';
|
||||
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||
@ -12,7 +12,6 @@ import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
@ -97,16 +96,11 @@ export class UserService extends TypeOrmQueryService<User> {
|
||||
const workspaceDataSource =
|
||||
await this.typeORMService.connectToDataSource(dataSourceMetadata);
|
||||
|
||||
const isPermissionsEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IsPermissionsEnabled,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const workspaceMembers = await workspaceDataSource?.query(
|
||||
`SELECT * FROM ${dataSourceMetadata.schema}."workspaceMember"`,
|
||||
);
|
||||
|
||||
if (isPermissionsEnabled && workspaceMembers.length > 1) {
|
||||
if (workspaceMembers.length > 1) {
|
||||
const userWorkspace =
|
||||
await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({
|
||||
userId,
|
||||
|
||||
@ -27,8 +27,6 @@ import {
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
|
||||
@ -83,7 +81,6 @@ export class UserResolver {
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
private readonly userRoleService: UserRoleService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
@Query(() => User)
|
||||
@ -103,38 +100,31 @@ export class UserResolver {
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
const permissionsEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IsPermissionsEnabled,
|
||||
workspace.id,
|
||||
const currentUserWorkspace = user.workspaces.find(
|
||||
(userWorkspace) => userWorkspace.workspace.id === workspace.id,
|
||||
);
|
||||
|
||||
if (permissionsEnabled === true) {
|
||||
const currentUserWorkspace = user.workspaces.find(
|
||||
(userWorkspace) => userWorkspace.workspace.id === workspace.id,
|
||||
);
|
||||
|
||||
if (!currentUserWorkspace) {
|
||||
throw new Error('Current user workspace not found');
|
||||
}
|
||||
const { settingsPermissions, objectRecordsPermissions } =
|
||||
await this.permissionsService.getUserWorkspacePermissions({
|
||||
userWorkspaceId: currentUserWorkspace.id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
const grantedSettingsPermissions: SettingPermissionType[] = (
|
||||
Object.keys(settingsPermissions) as SettingPermissionType[]
|
||||
).filter((feature) => settingsPermissions[feature] === true);
|
||||
|
||||
const grantedObjectRecordsPermissions = (
|
||||
Object.keys(objectRecordsPermissions) as PermissionsOnAllObjectRecords[]
|
||||
).filter((permission) => objectRecordsPermissions[permission] === true);
|
||||
|
||||
currentUserWorkspace.settingsPermissions = grantedSettingsPermissions;
|
||||
currentUserWorkspace.objectRecordsPermissions =
|
||||
grantedObjectRecordsPermissions;
|
||||
user.currentUserWorkspace = currentUserWorkspace;
|
||||
if (!currentUserWorkspace) {
|
||||
throw new Error('Current user workspace not found');
|
||||
}
|
||||
const { settingsPermissions, objectRecordsPermissions } =
|
||||
await this.permissionsService.getUserWorkspacePermissions({
|
||||
userWorkspaceId: currentUserWorkspace.id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
const grantedSettingsPermissions: SettingPermissionType[] = (
|
||||
Object.keys(settingsPermissions) as SettingPermissionType[]
|
||||
).filter((feature) => settingsPermissions[feature] === true);
|
||||
|
||||
const grantedObjectRecordsPermissions = (
|
||||
Object.keys(objectRecordsPermissions) as PermissionsOnAllObjectRecords[]
|
||||
).filter((permission) => objectRecordsPermissions[permission] === true);
|
||||
|
||||
currentUserWorkspace.settingsPermissions = grantedSettingsPermissions;
|
||||
currentUserWorkspace.objectRecordsPermissions =
|
||||
grantedObjectRecordsPermissions;
|
||||
user.currentUserWorkspace = currentUserWorkspace;
|
||||
|
||||
return {
|
||||
...user,
|
||||
@ -202,37 +192,31 @@ export class UserResolver {
|
||||
|
||||
const workspaceMembers: WorkspaceMember[] = [];
|
||||
|
||||
const permissionsEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IsPermissionsEnabled,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
let userWorkspacesByUserId = new Map<string, UserWorkspace>();
|
||||
let rolesByUserWorkspaces = new Map<string, RoleDTO[]>();
|
||||
|
||||
if (permissionsEnabled === true) {
|
||||
const userWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: {
|
||||
userId: In(workspaceMemberEntities.map((entity) => entity.userId)),
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
const userWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: {
|
||||
userId: In(workspaceMemberEntities.map((entity) => entity.userId)),
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
userWorkspacesByUserId = new Map(
|
||||
userWorkspaces.map((userWorkspace) => [
|
||||
userWorkspace.userId,
|
||||
userWorkspace,
|
||||
]),
|
||||
);
|
||||
userWorkspacesByUserId = new Map(
|
||||
userWorkspaces.map((userWorkspace) => [
|
||||
userWorkspace.userId,
|
||||
userWorkspace,
|
||||
]),
|
||||
);
|
||||
|
||||
rolesByUserWorkspaces =
|
||||
await this.userRoleService.getRolesByUserWorkspaces({
|
||||
userWorkspaceIds: userWorkspaces.map(
|
||||
(userWorkspace) => userWorkspace.id,
|
||||
),
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
rolesByUserWorkspaces = await this.userRoleService.getRolesByUserWorkspaces(
|
||||
{
|
||||
userWorkspaceIds: userWorkspaces.map(
|
||||
(userWorkspace) => userWorkspace.id,
|
||||
),
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
);
|
||||
|
||||
for (const workspaceMemberEntity of workspaceMemberEntities) {
|
||||
if (workspaceMemberEntity.avatarUrl) {
|
||||
@ -246,39 +230,37 @@ export class UserResolver {
|
||||
|
||||
const workspaceMember = workspaceMemberEntity as WorkspaceMember;
|
||||
|
||||
if (permissionsEnabled === true) {
|
||||
const userWorkspace = userWorkspacesByUserId.get(
|
||||
workspaceMemberEntity.userId,
|
||||
);
|
||||
const userWorkspace = userWorkspacesByUserId.get(
|
||||
workspaceMemberEntity.userId,
|
||||
);
|
||||
|
||||
if (!userWorkspace) {
|
||||
throw new Error('User workspace not found');
|
||||
}
|
||||
|
||||
workspaceMember.userWorkspaceId = userWorkspace.id;
|
||||
|
||||
const workspaceMemberRoles = (
|
||||
rolesByUserWorkspaces.get(userWorkspace.id) ?? []
|
||||
).map((roleEntity) => {
|
||||
return {
|
||||
id: roleEntity.id,
|
||||
label: roleEntity.label,
|
||||
canUpdateAllSettings: roleEntity.canUpdateAllSettings,
|
||||
description: roleEntity.description,
|
||||
icon: roleEntity.icon,
|
||||
isEditable: roleEntity.isEditable,
|
||||
userWorkspaceRoles: roleEntity.userWorkspaceRoles,
|
||||
canReadAllObjectRecords: roleEntity.canReadAllObjectRecords,
|
||||
canUpdateAllObjectRecords: roleEntity.canUpdateAllObjectRecords,
|
||||
canSoftDeleteAllObjectRecords:
|
||||
roleEntity.canSoftDeleteAllObjectRecords,
|
||||
canDestroyAllObjectRecords: roleEntity.canDestroyAllObjectRecords,
|
||||
};
|
||||
});
|
||||
|
||||
workspaceMember.roles = workspaceMemberRoles;
|
||||
if (!userWorkspace) {
|
||||
throw new Error('User workspace not found');
|
||||
}
|
||||
|
||||
workspaceMember.userWorkspaceId = userWorkspace.id;
|
||||
|
||||
const workspaceMemberRoles = (
|
||||
rolesByUserWorkspaces.get(userWorkspace.id) ?? []
|
||||
).map((roleEntity) => {
|
||||
return {
|
||||
id: roleEntity.id,
|
||||
label: roleEntity.label,
|
||||
canUpdateAllSettings: roleEntity.canUpdateAllSettings,
|
||||
description: roleEntity.description,
|
||||
icon: roleEntity.icon,
|
||||
isEditable: roleEntity.isEditable,
|
||||
userWorkspaceRoles: roleEntity.userWorkspaceRoles,
|
||||
canReadAllObjectRecords: roleEntity.canReadAllObjectRecords,
|
||||
canUpdateAllObjectRecords: roleEntity.canUpdateAllObjectRecords,
|
||||
canSoftDeleteAllObjectRecords:
|
||||
roleEntity.canSoftDeleteAllObjectRecords,
|
||||
canDestroyAllObjectRecords: roleEntity.canDestroyAllObjectRecords,
|
||||
};
|
||||
});
|
||||
|
||||
workspaceMember.roles = workspaceMemberRoles;
|
||||
|
||||
workspaceMembers.push(workspaceMember);
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,6 @@ import { CustomDomainService } from 'src/engine/core-modules/domain-manager/serv
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import {
|
||||
FileWorkspaceFolderDeletionJob,
|
||||
@ -154,26 +153,19 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
||||
|
||||
const permissionsEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IsPermissionsEnabled,
|
||||
workspace.id,
|
||||
);
|
||||
await this.validateSecurityPermissions({
|
||||
payload,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
if (permissionsEnabled) {
|
||||
await this.validateSecurityPermissions({
|
||||
payload,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
await this.validateWorkspacePermissions({
|
||||
payload,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
apiKey,
|
||||
});
|
||||
}
|
||||
await this.validateWorkspacePermissions({
|
||||
payload,
|
||||
userWorkspaceId,
|
||||
workspaceId: workspace.id,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
if (payload.subdomain && workspace.subdomain !== payload.subdomain) {
|
||||
await this.validateSubdomainUpdate(payload.subdomain);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@ -11,7 +12,6 @@ import {
|
||||
Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
|
||||
@ -9,8 +9,6 @@ import { GqlExecutionContext } from '@nestjs/graphql';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
|
||||
import {
|
||||
PermissionsException,
|
||||
@ -24,24 +22,11 @@ export const SettingsPermissionsGuard = (
|
||||
): Type<CanActivate> => {
|
||||
@Injectable()
|
||||
class SettingsPermissionsMixin implements CanActivate {
|
||||
constructor(
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {}
|
||||
constructor(private readonly permissionsService: PermissionsService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
const workspaceId = ctx.getContext().req.workspace.id;
|
||||
|
||||
const permissionsEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IsPermissionsEnabled,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!permissionsEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userWorkspaceId = ctx.getContext().req.userWorkspaceId;
|
||||
|
||||
const hasPermission =
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { DEV_SEED_USER_WORKSPACE_IDS } from 'src/database/typeorm-seeds/core/user-workspaces';
|
||||
@ -8,7 +9,6 @@ import {
|
||||
SEED_ACME_WORKSPACE_ID,
|
||||
SEED_APPLE_WORKSPACE_ID,
|
||||
} from 'src/database/typeorm-seeds/core/workspaces';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@ -315,21 +315,16 @@ export class WorkspaceManagerService {
|
||||
roleId: adminRole.id,
|
||||
});
|
||||
|
||||
await this.roleService.createMemberRole({
|
||||
const memberRole = await this.roleService.createMemberRole({
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
// Temporary - after permissions are rolled-out we will set member role as the default role
|
||||
await this.workspaceRepository.update(workspaceId, {
|
||||
defaultRoleId: adminRole.id,
|
||||
defaultRoleId: memberRole.id,
|
||||
});
|
||||
}
|
||||
|
||||
private async initPermissionsDev(workspaceId: string) {
|
||||
await this.featureFlagService.enableFeatureFlags(
|
||||
[FeatureFlagKey.IsPermissionsEnabled],
|
||||
workspaceId,
|
||||
);
|
||||
const adminRole = await this.roleService.createAdminRole({
|
||||
workspaceId,
|
||||
});
|
||||
@ -369,6 +364,7 @@ export class WorkspaceManagerService {
|
||||
|
||||
await this.workspaceRepository.update(workspaceId, {
|
||||
defaultRoleId: memberRole.id,
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
});
|
||||
|
||||
if (memberUserWorkspaceId) {
|
||||
|
||||
Reference in New Issue
Block a user