mirror of
https://github.com/bitwarden/browser.git
synced 2025-02-18 01:41:27 +01:00
[AC-1374] Limit collection create/delete (#5963)
* feat: udate request/response/data/domain models for new column, refs AC-1374 * feat: create collection management ui, refs AC-1374 * fix: remove limitCollectionCdOwnerAdmin boolean from org update request, refs AC-1374 * fix: moved collection management UI, removed comments, refs AC-1374 * fix: observable chaining now properly calls API when local org updated, refs AC-1374 * fix: remove unused form template variables, refs AC-1374 * fix: clean up observable chain, refs AC-1374 * fix: remove parent.parent route, refs AC-1374 * fix: add cd explaination, refs AC-1374
This commit is contained in:
parent
c3bcd732cf
commit
7bcf408056
@ -7,7 +7,7 @@
|
|||||||
></i>
|
></i>
|
||||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||||
</div>
|
</div>
|
||||||
<form *ngIf="org && !loading" #form [bitSubmit]="submit" [formGroup]="formGroup">
|
<form *ngIf="org && !loading" [bitSubmit]="submit" [formGroup]="formGroup">
|
||||||
<div class="tw-grid tw-grid-cols-2 tw-gap-5">
|
<div class="tw-grid tw-grid-cols-2 tw-gap-5">
|
||||||
<div>
|
<div>
|
||||||
<bit-form-field>
|
<bit-form-field>
|
||||||
@ -52,6 +52,27 @@
|
|||||||
{{ "rotateApiKey" | i18n }}
|
{{ "rotateApiKey" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
<form
|
||||||
|
*ngIf="org && !loading"
|
||||||
|
[bitSubmit]="submitCollectionManagement"
|
||||||
|
[formGroup]="collectionManagementFormGroup"
|
||||||
|
>
|
||||||
|
<h1 bitTypography="h1" class="tw-mt-16 tw-pb-2.5">{{ "collectionManagement" | i18n }}</h1>
|
||||||
|
<p>{{ "collectionManagementDesc" | i18n }}</p>
|
||||||
|
<bit-form-control>
|
||||||
|
<bit-label>{{ "limitCollectionCdOwnerAdminDesc" | i18n }}</bit-label>
|
||||||
|
<input type="checkbox" bitCheckbox formControlName="limitCollectionCdOwnerAdmin" />
|
||||||
|
</bit-form-control>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
bitButton
|
||||||
|
bitFormButton
|
||||||
|
buttonType="primary"
|
||||||
|
id="collectionManagementSubmitButton"
|
||||||
|
>
|
||||||
|
{{ "save" | i18n }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<h1 bitTypography="h1" class="tw-mt-16 tw-pb-2.5 !tw-text-danger">{{ "dangerZone" | i18n }}</h1>
|
<h1 bitTypography="h1" class="tw-mt-16 tw-pb-2.5 !tw-text-danger">{{ "dangerZone" | i18n }}</h1>
|
||||||
<div class="tw-rounded tw-border tw-border-solid tw-border-danger-500 tw-bg-background tw-p-5">
|
<div class="tw-rounded tw-border tw-border-solid tw-border-danger-500 tw-bg-background tw-p-5">
|
||||||
<p>{{ "dangerZoneDesc" | i18n }}</p>
|
<p>{{ "dangerZoneDesc" | i18n }}</p>
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
import { Component, ViewChild, ViewContainerRef } from "@angular/core";
|
import { Component, ViewChild, ViewContainerRef } from "@angular/core";
|
||||||
import { FormBuilder, Validators } from "@angular/forms";
|
import { FormBuilder, Validators } from "@angular/forms";
|
||||||
import { ActivatedRoute, Router } from "@angular/router";
|
import { ActivatedRoute, Router } from "@angular/router";
|
||||||
import { combineLatest, lastValueFrom, Subject, switchMap, takeUntil, from } from "rxjs";
|
import { combineLatest, lastValueFrom, Subject, switchMap, takeUntil, from, of } from "rxjs";
|
||||||
|
|
||||||
import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog";
|
import { DialogServiceAbstraction } from "@bitwarden/angular/services/dialog";
|
||||||
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||||
|
import { OrganizationCollectionManagementUpdateRequest } from "@bitwarden/common/admin-console/models/request/organization-collection-management-update.request";
|
||||||
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
|
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
|
||||||
import { OrganizationUpdateRequest } from "@bitwarden/common/admin-console/models/request/organization-update.request";
|
import { OrganizationUpdateRequest } from "@bitwarden/common/admin-console/models/request/organization-update.request";
|
||||||
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
|
import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response";
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
|
||||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||||
|
|
||||||
@ -38,7 +38,6 @@ export class AccountComponent {
|
|||||||
loading = true;
|
loading = true;
|
||||||
canUseApi = false;
|
canUseApi = false;
|
||||||
org: OrganizationResponse;
|
org: OrganizationResponse;
|
||||||
formPromise: Promise<OrganizationResponse>;
|
|
||||||
taxFormPromise: Promise<unknown>;
|
taxFormPromise: Promise<unknown>;
|
||||||
|
|
||||||
// FormGroup validators taken from server Organization domain object
|
// FormGroup validators taken from server Organization domain object
|
||||||
@ -60,6 +59,10 @@ export class AccountComponent {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
protected collectionManagementFormGroup = this.formBuilder.group({
|
||||||
|
limitCollectionCdOwnerAdmin: [false],
|
||||||
|
});
|
||||||
|
|
||||||
protected organizationId: string;
|
protected organizationId: string;
|
||||||
protected publicKeyBuffer: Uint8Array;
|
protected publicKeyBuffer: Uint8Array;
|
||||||
|
|
||||||
@ -71,7 +74,6 @@ export class AccountComponent {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private platformUtilsService: PlatformUtilsService,
|
private platformUtilsService: PlatformUtilsService,
|
||||||
private cryptoService: CryptoService,
|
private cryptoService: CryptoService,
|
||||||
private logService: LogService,
|
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private organizationService: OrganizationService,
|
private organizationService: OrganizationService,
|
||||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||||
@ -82,16 +84,16 @@ export class AccountComponent {
|
|||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.selfHosted = this.platformUtilsService.isSelfHost();
|
this.selfHosted = this.platformUtilsService.isSelfHost();
|
||||||
|
|
||||||
this.route.parent.parent.params
|
this.route.params
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap((params) => {
|
switchMap((params) => this.organizationService.get$(params.organizationId)),
|
||||||
|
switchMap((organization) => {
|
||||||
return combineLatest([
|
return combineLatest([
|
||||||
// Organization domain
|
of(organization),
|
||||||
this.organizationService.get$(params.organizationId),
|
|
||||||
// OrganizationResponse for form population
|
// OrganizationResponse for form population
|
||||||
from(this.organizationApiService.get(params.organizationId)),
|
from(this.organizationApiService.get(organization.id)),
|
||||||
// Organization Public Key
|
// Organization Public Key
|
||||||
from(this.organizationApiService.getKeys(params.organizationId)),
|
from(this.organizationApiService.getKeys(organization.id)),
|
||||||
]);
|
]);
|
||||||
}),
|
}),
|
||||||
takeUntil(this.destroy$)
|
takeUntil(this.destroy$)
|
||||||
@ -102,6 +104,16 @@ export class AccountComponent {
|
|||||||
this.canEditSubscription = organization.canEditSubscription;
|
this.canEditSubscription = organization.canEditSubscription;
|
||||||
this.canUseApi = organization.useApi;
|
this.canUseApi = organization.useApi;
|
||||||
|
|
||||||
|
// Update disabled states - reactive forms prefers not using disabled attribute
|
||||||
|
if (!this.selfHosted) {
|
||||||
|
this.formGroup.get("orgName").enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.selfHosted || this.canEditSubscription) {
|
||||||
|
this.formGroup.get("billingEmail").enable();
|
||||||
|
this.formGroup.get("businessName").enable();
|
||||||
|
}
|
||||||
|
|
||||||
// Org Response
|
// Org Response
|
||||||
this.org = orgResponse;
|
this.org = orgResponse;
|
||||||
|
|
||||||
@ -114,16 +126,9 @@ export class AccountComponent {
|
|||||||
billingEmail: this.org.billingEmail,
|
billingEmail: this.org.billingEmail,
|
||||||
businessName: this.org.businessName,
|
businessName: this.org.businessName,
|
||||||
});
|
});
|
||||||
|
this.collectionManagementFormGroup.patchValue({
|
||||||
// Update disabled states - reactive forms prefers not using disabled attribute
|
limitCollectionCdOwnerAdmin: this.org.limitCollectionCdOwnerAdmin,
|
||||||
if (!this.selfHosted) {
|
});
|
||||||
this.formGroup.get("orgName").enable();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.selfHosted || this.canEditSubscription) {
|
|
||||||
this.formGroup.get("billingEmail").enable();
|
|
||||||
this.formGroup.get("businessName").enable();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
});
|
});
|
||||||
@ -153,11 +158,25 @@ export class AccountComponent {
|
|||||||
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
|
request.keys = new OrganizationKeysRequest(orgKeys[0], orgKeys[1].encryptedString);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.formPromise = this.organizationApiService.save(this.organizationId, request);
|
await this.organizationApiService.save(this.organizationId, request);
|
||||||
await this.formPromise;
|
|
||||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("organizationUpdated"));
|
this.platformUtilsService.showToast("success", null, this.i18nService.t("organizationUpdated"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
submitCollectionManagement = async () => {
|
||||||
|
const request = new OrganizationCollectionManagementUpdateRequest();
|
||||||
|
request.limitCreateDeleteOwnerAdmin =
|
||||||
|
this.collectionManagementFormGroup.value.limitCollectionCdOwnerAdmin;
|
||||||
|
|
||||||
|
await this.organizationApiService.updateCollectionManagement(this.organizationId, request);
|
||||||
|
|
||||||
|
this.platformUtilsService.showToast(
|
||||||
|
"success",
|
||||||
|
null,
|
||||||
|
this.i18nService.t("collectionManagementUpdated")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
async deleteOrganization() {
|
async deleteOrganization() {
|
||||||
const dialog = openDeleteOrganizationDialog(this.dialogService, {
|
const dialog = openDeleteOrganizationDialog(this.dialogService, {
|
||||||
data: {
|
data: {
|
||||||
|
@ -7073,6 +7073,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"collectionManagement": {
|
||||||
|
"message": "Collection management"
|
||||||
|
},
|
||||||
|
"collectionManagementDesc": {
|
||||||
|
"message": "Manage the collection behavior for the organization"
|
||||||
|
},
|
||||||
|
"limitCollectionCdOwnerAdminDesc": {
|
||||||
|
"message": "Limit collection creation and deletion to owners and admins"
|
||||||
|
},
|
||||||
|
"collectionManagementUpdated": {
|
||||||
|
"message": "Collection management behavior saved"
|
||||||
|
},
|
||||||
"passwordManagerPlanPrice": {
|
"passwordManagerPlanPrice": {
|
||||||
"message": "Password Manager plan price"
|
"message": "Password Manager plan price"
|
||||||
},
|
},
|
||||||
|
@ -18,6 +18,7 @@ import { StorageRequest } from "../../../models/request/storage.request";
|
|||||||
import { VerifyBankRequest } from "../../../models/request/verify-bank.request";
|
import { VerifyBankRequest } from "../../../models/request/verify-bank.request";
|
||||||
import { ListResponse } from "../../../models/response/list.response";
|
import { ListResponse } from "../../../models/response/list.response";
|
||||||
import { OrganizationApiKeyType } from "../../enums";
|
import { OrganizationApiKeyType } from "../../enums";
|
||||||
|
import { OrganizationCollectionManagementUpdateRequest } from "../../models/request/organization-collection-management-update.request";
|
||||||
import { OrganizationCreateRequest } from "../../models/request/organization-create.request";
|
import { OrganizationCreateRequest } from "../../models/request/organization-create.request";
|
||||||
import { OrganizationKeysRequest } from "../../models/request/organization-keys.request";
|
import { OrganizationKeysRequest } from "../../models/request/organization-keys.request";
|
||||||
import { OrganizationUpdateRequest } from "../../models/request/organization-update.request";
|
import { OrganizationUpdateRequest } from "../../models/request/organization-update.request";
|
||||||
@ -73,4 +74,8 @@ export class OrganizationApiServiceAbstraction {
|
|||||||
id: string,
|
id: string,
|
||||||
request: SecretsManagerSubscribeRequest
|
request: SecretsManagerSubscribeRequest
|
||||||
) => Promise<ProfileOrganizationResponse>;
|
) => Promise<ProfileOrganizationResponse>;
|
||||||
|
updateCollectionManagement: (
|
||||||
|
id: string,
|
||||||
|
request: OrganizationCollectionManagementUpdateRequest
|
||||||
|
) => Promise<OrganizationResponse>;
|
||||||
}
|
}
|
||||||
|
@ -49,6 +49,7 @@ export class OrganizationData {
|
|||||||
familySponsorshipValidUntil?: Date;
|
familySponsorshipValidUntil?: Date;
|
||||||
familySponsorshipToDelete?: boolean;
|
familySponsorshipToDelete?: boolean;
|
||||||
accessSecretsManager: boolean;
|
accessSecretsManager: boolean;
|
||||||
|
limitCollectionCdOwnerAdmin: boolean;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
response: ProfileOrganizationResponse,
|
response: ProfileOrganizationResponse,
|
||||||
@ -100,6 +101,7 @@ export class OrganizationData {
|
|||||||
this.familySponsorshipValidUntil = response.familySponsorshipValidUntil;
|
this.familySponsorshipValidUntil = response.familySponsorshipValidUntil;
|
||||||
this.familySponsorshipToDelete = response.familySponsorshipToDelete;
|
this.familySponsorshipToDelete = response.familySponsorshipToDelete;
|
||||||
this.accessSecretsManager = response.accessSecretsManager;
|
this.accessSecretsManager = response.accessSecretsManager;
|
||||||
|
this.limitCollectionCdOwnerAdmin = response.limitCollectionCdOwnerAdmin;
|
||||||
|
|
||||||
this.isMember = options.isMember;
|
this.isMember = options.isMember;
|
||||||
this.isProviderUser = options.isProviderUser;
|
this.isProviderUser = options.isProviderUser;
|
||||||
|
@ -64,6 +64,10 @@ export class Organization {
|
|||||||
familySponsorshipValidUntil?: Date;
|
familySponsorshipValidUntil?: Date;
|
||||||
familySponsorshipToDelete?: boolean;
|
familySponsorshipToDelete?: boolean;
|
||||||
accessSecretsManager: boolean;
|
accessSecretsManager: boolean;
|
||||||
|
/**
|
||||||
|
* Refers to the ability for an organization to limit collection creation and deletion to owners and admins only
|
||||||
|
*/
|
||||||
|
limitCollectionCdOwnerAdmin: boolean;
|
||||||
|
|
||||||
constructor(obj?: OrganizationData) {
|
constructor(obj?: OrganizationData) {
|
||||||
if (obj == null) {
|
if (obj == null) {
|
||||||
@ -115,6 +119,7 @@ export class Organization {
|
|||||||
this.familySponsorshipValidUntil = obj.familySponsorshipValidUntil;
|
this.familySponsorshipValidUntil = obj.familySponsorshipValidUntil;
|
||||||
this.familySponsorshipToDelete = obj.familySponsorshipToDelete;
|
this.familySponsorshipToDelete = obj.familySponsorshipToDelete;
|
||||||
this.accessSecretsManager = obj.accessSecretsManager;
|
this.accessSecretsManager = obj.accessSecretsManager;
|
||||||
|
this.limitCollectionCdOwnerAdmin = obj.limitCollectionCdOwnerAdmin;
|
||||||
}
|
}
|
||||||
|
|
||||||
get canAccess() {
|
get canAccess() {
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
export class OrganizationCollectionManagementUpdateRequest {
|
||||||
|
limitCreateDeleteOwnerAdmin: boolean;
|
||||||
|
}
|
@ -33,6 +33,7 @@ export class OrganizationResponse extends BaseResponse {
|
|||||||
smServiceAccounts?: number;
|
smServiceAccounts?: number;
|
||||||
maxAutoscaleSmSeats?: number;
|
maxAutoscaleSmSeats?: number;
|
||||||
maxAutoscaleSmServiceAccounts?: number;
|
maxAutoscaleSmServiceAccounts?: number;
|
||||||
|
limitCollectionCdOwnerAdmin: boolean;
|
||||||
|
|
||||||
constructor(response: any) {
|
constructor(response: any) {
|
||||||
super(response);
|
super(response);
|
||||||
@ -72,5 +73,6 @@ export class OrganizationResponse extends BaseResponse {
|
|||||||
this.smServiceAccounts = this.getResponseProperty("SmServiceAccounts");
|
this.smServiceAccounts = this.getResponseProperty("SmServiceAccounts");
|
||||||
this.maxAutoscaleSmSeats = this.getResponseProperty("MaxAutoscaleSmSeats");
|
this.maxAutoscaleSmSeats = this.getResponseProperty("MaxAutoscaleSmSeats");
|
||||||
this.maxAutoscaleSmServiceAccounts = this.getResponseProperty("MaxAutoscaleSmServiceAccounts");
|
this.maxAutoscaleSmServiceAccounts = this.getResponseProperty("MaxAutoscaleSmServiceAccounts");
|
||||||
|
this.limitCollectionCdOwnerAdmin = this.getResponseProperty("LimitCollectionCdOwnerAdmin");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,6 +48,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
|||||||
familySponsorshipValidUntil?: Date;
|
familySponsorshipValidUntil?: Date;
|
||||||
familySponsorshipToDelete?: boolean;
|
familySponsorshipToDelete?: boolean;
|
||||||
accessSecretsManager: boolean;
|
accessSecretsManager: boolean;
|
||||||
|
limitCollectionCdOwnerAdmin: boolean;
|
||||||
|
|
||||||
constructor(response: any) {
|
constructor(response: any) {
|
||||||
super(response);
|
super(response);
|
||||||
@ -105,5 +106,6 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
|||||||
}
|
}
|
||||||
this.familySponsorshipToDelete = this.getResponseProperty("FamilySponsorshipToDelete");
|
this.familySponsorshipToDelete = this.getResponseProperty("FamilySponsorshipToDelete");
|
||||||
this.accessSecretsManager = this.getResponseProperty("AccessSecretsManager");
|
this.accessSecretsManager = this.getResponseProperty("AccessSecretsManager");
|
||||||
|
this.limitCollectionCdOwnerAdmin = this.getResponseProperty("LimitCollectionCdOwnerAdmin");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ import { ListResponse } from "../../../models/response/list.response";
|
|||||||
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
|
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
|
||||||
import { OrganizationApiServiceAbstraction } from "../../abstractions/organization/organization-api.service.abstraction";
|
import { OrganizationApiServiceAbstraction } from "../../abstractions/organization/organization-api.service.abstraction";
|
||||||
import { OrganizationApiKeyType } from "../../enums";
|
import { OrganizationApiKeyType } from "../../enums";
|
||||||
|
import { OrganizationCollectionManagementUpdateRequest } from "../../models/request/organization-collection-management-update.request";
|
||||||
import { OrganizationCreateRequest } from "../../models/request/organization-create.request";
|
import { OrganizationCreateRequest } from "../../models/request/organization-create.request";
|
||||||
import { OrganizationKeysRequest } from "../../models/request/organization-keys.request";
|
import { OrganizationKeysRequest } from "../../models/request/organization-keys.request";
|
||||||
import { OrganizationUpdateRequest } from "../../models/request/organization-update.request";
|
import { OrganizationUpdateRequest } from "../../models/request/organization-update.request";
|
||||||
@ -322,4 +323,20 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
|
|||||||
);
|
);
|
||||||
return new ProfileOrganizationResponse(r);
|
return new ProfileOrganizationResponse(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateCollectionManagement(
|
||||||
|
id: string,
|
||||||
|
request: OrganizationCollectionManagementUpdateRequest
|
||||||
|
): Promise<OrganizationResponse> {
|
||||||
|
const r = await this.apiService.send(
|
||||||
|
"PUT",
|
||||||
|
"/organizations/" + id + "/collection-management",
|
||||||
|
request,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const data = new OrganizationResponse(r);
|
||||||
|
await this.syncService.fullSync(true);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user