1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +00:00

[PM-22107] Update Remove Individual Vault policy dialog (#15323)

* WIP

* switch to signal

* fix ts strict errors

* clean up

* refactor policy list service

* implement vnext component

* refactor to include feature flag check in display()

* CR feedback

* refactor submit to cancel before request is built

* clean up

* Fix typo

---------

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
This commit is contained in:
Brandon Treston
2025-08-06 09:34:43 -04:00
committed by GitHub
parent 61cd0c4f51
commit 29e16fc5e0
17 changed files with 276 additions and 76 deletions

View File

@@ -1,12 +1,12 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, Input, OnInit } from "@angular/core";
import { UntypedFormControl, UntypedFormGroup } from "@angular/forms";
import { Observable, of } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { PolicyRequest } from "@bitwarden/common/admin-console/models/request/policy.request";
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
export abstract class BasePolicy {
abstract name: string;
@@ -14,38 +14,56 @@ export abstract class BasePolicy {
abstract type: PolicyType;
abstract component: any;
display(organization: Organization) {
return true;
/**
* If true, the description will be reused in the policy edit modal. Set this to false if you
* have more complex requirements that you will implement in your template instead.
**/
showDescription: boolean = true;
display(organization: Organization, configService: ConfigService): Observable<boolean> {
return of(true);
}
}
@Directive()
export abstract class BasePolicyComponent implements OnInit {
@Input() policyResponse: PolicyResponse;
@Input() policy: BasePolicy;
@Input() policyResponse: PolicyResponse | undefined;
@Input() policy: BasePolicy | undefined;
enabled = new UntypedFormControl(false);
data: UntypedFormGroup = null;
data: UntypedFormGroup | undefined;
ngOnInit(): void {
this.enabled.setValue(this.policyResponse.enabled);
this.enabled.setValue(this.policyResponse?.enabled);
if (this.policyResponse.data != null) {
if (this.policyResponse?.data != null) {
this.loadData();
}
}
buildRequest() {
const request = new PolicyRequest();
request.enabled = this.enabled.value;
request.type = this.policy.type;
request.data = this.buildRequestData();
if (!this.policy) {
throw new Error("Policy was not found");
}
const request: PolicyRequest = {
type: this.policy.type,
enabled: this.enabled.value,
data: this.buildRequestData(),
};
return Promise.resolve(request);
}
/**
* Enable optional validation before sumitting a respose for policy submission
* */
confirm(): Promise<boolean> | boolean {
return true;
}
protected loadData() {
this.data.patchValue(this.policyResponse.data ?? {});
this.data?.patchValue(this.policyResponse?.data ?? {});
}
protected buildRequestData() {

View File

@@ -3,6 +3,7 @@ export { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export { DisableSendPolicy } from "./disable-send.component";
export { MasterPasswordPolicy } from "./master-password.component";
export { PasswordGeneratorPolicy } from "./password-generator.component";
export { vNextOrganizationDataOwnershipPolicy } from "./vnext-organization-data-ownership.component";
export { OrganizationDataOwnershipPolicy } from "./organization-data-ownership.component";
export { RequireSsoPolicy } from "./require-sso.component";
export { ResetPasswordPolicy } from "./reset-password.component";

View File

@@ -1,6 +1,10 @@
import { Component } from "@angular/core";
import { map, Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -9,6 +13,12 @@ export class OrganizationDataOwnershipPolicy extends BasePolicy {
description = "personalOwnershipPolicyDesc";
type = PolicyType.OrganizationDataOwnership;
component = OrganizationDataOwnershipPolicyComponent;
display(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService
.getFeatureFlag$(FeatureFlag.CreateDefaultLocation)
.pipe(map((enabled) => !enabled));
}
}
@Component({

View File

@@ -1,38 +1,45 @@
<app-header>
@let organization = organization$ | async;
<button
bitBadge
class="!tw-align-middle"
(click)="changePlan(organization)"
*ngIf="isBreadcrumbingEnabled$ | async"
slot="title-suffix"
type="button"
variant="primary"
>
{{ "upgrade" | i18n }}
</button>
@if (isBreadcrumbingEnabled$ | async) {
<button
bitBadge
class="!tw-align-middle"
(click)="changePlan(organization)"
slot="title-suffix"
type="button"
variant="primary"
>
{{ "upgrade" | i18n }}
</button>
}
</app-header>
<bit-container>
<ng-container *ngIf="loading">
@if (loading) {
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</ng-container>
<bit-table *ngIf="!loading">
<ng-template body>
<tr bitRow *ngFor="let p of policies">
<td bitCell *ngIf="p.display(organization)" ngPreserveWhitespaces>
<button type="button" bitLink (click)="edit(p)">{{ p.name | i18n }}</button>
<span bitBadge variant="success" *ngIf="policiesEnabledMap.get(p.type)">{{
"on" | i18n
}}</span>
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
</td>
</tr>
</ng-template>
</bit-table>
}
@if (!loading) {
<bit-table>
<ng-template body>
@for (p of policies; track p.name) {
@if (p.display(organization, configService) | async) {
<tr bitRow>
<td bitCell ngPreserveWhitespaces>
<button type="button" bitLink (click)="edit(p)">{{ p.name | i18n }}</button>
@if (policiesEnabledMap.get(p.type)) {
<span bitBadge variant="success">{{ "on" | i18n }}</span>
}
<small class="tw-text-muted tw-block">{{ p.description | i18n }}</small>
</td>
</tr>
}
}
</ng-template>
</bit-table>
}
</bit-container>

View File

@@ -15,7 +15,6 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/policy.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import {
@@ -25,7 +24,7 @@ import {
import { All } from "@bitwarden/web-vault/app/vault/individual-vault/vault-filter/shared/models/routed-vault-filter.model";
import { PolicyListService } from "../../core/policy-list.service";
import { BasePolicy, RestrictedItemTypesPolicy } from "../policies";
import { BasePolicy } from "../policies";
import { CollectionDialogTabType } from "../shared/components/collection-dialog";
import { PolicyEditComponent, PolicyEditDialogResult } from "./policy-edit.component";
@@ -53,7 +52,7 @@ export class PoliciesComponent implements OnInit {
private policyListService: PolicyListService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private dialogService: DialogService,
private configService: ConfigService,
protected configService: ConfigService,
) {}
async ngOnInit() {
@@ -71,35 +70,31 @@ export class PoliciesComponent implements OnInit {
await this.load();
// Handle policies component launch from Event message
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
if (qParams.policyId != null) {
const policyIdFromEvents: string = qParams.policyId;
for (const orgPolicy of this.orgPolicies) {
if (orgPolicy.id === policyIdFromEvents) {
for (let i = 0; i < this.policies.length; i++) {
if (this.policies[i].type === orgPolicy.type) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.edit(this.policies[i]);
break;
this.route.queryParams
.pipe(first())
/* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */
.subscribe(async (qParams) => {
if (qParams.policyId != null) {
const policyIdFromEvents: string = qParams.policyId;
for (const orgPolicy of this.orgPolicies) {
if (orgPolicy.id === policyIdFromEvents) {
for (let i = 0; i < this.policies.length; i++) {
if (this.policies[i].type === orgPolicy.type) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.edit(this.policies[i]);
break;
}
}
break;
}
break;
}
}
}
});
});
});
}
async load() {
if (
(await this.configService.getFeatureFlag(FeatureFlag.RemoveCardItemTypePolicy)) &&
this.policyListService.getPolicies().every((p) => !(p instanceof RestrictedItemTypesPolicy))
) {
this.policyListService.addPolicies([new RestrictedItemTypesPolicy()]);
}
const response = await this.policyApiService.getPolicies(this.organizationId);
this.orgPolicies = response.data != null && response.data.length > 0 ? response.data : [];
this.orgPolicies.forEach((op) => {

View File

@@ -22,7 +22,9 @@
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<div [hidden]="loading">
<p bitTypography="body1">{{ policy.description | i18n }}</p>
@if (policy.showDescription) {
<p bitTypography="body1">{{ policy.description | i18n }}</p>
}
<ng-template #policyForm></ng-template>
</div>
</ng-container>

View File

@@ -128,13 +128,20 @@ export class PolicyEditComponent implements AfterViewInit {
}
submit = async () => {
if ((await this.policyComponent.confirm()) == false) {
this.dialogRef.close();
return;
}
let request: PolicyRequest;
try {
request = await this.policyComponent.buildRequest();
} catch (e) {
this.toastService.showToast({ variant: "error", title: null, message: e.message });
return;
}
await this.policyApiService.putPolicy(this.data.organizationId, this.data.policy.type, request);
this.toastService.showToast({
variant: "success",

View File

@@ -1,7 +1,9 @@
import { Component } from "@angular/core";
import { of } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -11,8 +13,8 @@ export class RequireSsoPolicy extends BasePolicy {
type = PolicyType.RequireSso;
component = RequireSsoPolicyComponent;
display(organization: Organization) {
return organization.useSso;
display(organization: Organization, configService: ConfigService) {
return of(organization.useSso);
}
}

View File

@@ -1,6 +1,6 @@
import { Component, OnInit } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { firstValueFrom, of } from "rxjs";
import {
getOrganizationById,
@@ -10,6 +10,7 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -19,8 +20,8 @@ export class ResetPasswordPolicy extends BasePolicy {
type = PolicyType.ResetPassword;
component = ResetPasswordPolicyComponent;
display(organization: Organization) {
return organization.useResetPassword;
display(organization: Organization, configService: ConfigService) {
return of(organization.useResetPassword);
}
}
@@ -52,6 +53,10 @@ export class ResetPasswordPolicyComponent extends BasePolicyComponent implements
throw new Error("No user found.");
}
if (!this.policyResponse) {
throw new Error("Policies not found");
}
const organization = await firstValueFrom(
this.organizationService
.organizations$(userId)

View File

@@ -1,6 +1,10 @@
import { Component } from "@angular/core";
import { Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -9,6 +13,10 @@ export class RestrictedItemTypesPolicy extends BasePolicy {
description = "restrictedItemTypePolicyDesc";
type = PolicyType.RestrictedItemTypes;
component = RestrictedItemTypesPolicyComponent;
display(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService.getFeatureFlag$(FeatureFlag.RemoveCardItemTypePolicy);
}
}
@Component({

View File

@@ -20,6 +20,9 @@ export class SingleOrgPolicyComponent extends BasePolicyComponent implements OnI
async ngOnInit() {
super.ngOnInit();
if (!this.policyResponse) {
throw new Error("Policies not found");
}
if (!this.policyResponse.canToggleState) {
this.enabled.disable();
}

View File

@@ -0,0 +1,57 @@
<p>
{{ "organizationDataOwnershipContent" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</p>
<bit-form-control>
<input type="checkbox" bitCheckbox [formControl]="enabled" id="enabled" />
<bit-label>{{ "turnOn" | i18n }}</bit-label>
</bit-form-control>
<ng-template #dialog>
<bit-simple-dialog background="alt">
<span bitDialogTitle>{{ "organizationDataOwnershipWarningTitle" | i18n }}</span>
<ng-container bitDialogContent>
<div class="tw-text-left tw-overflow-hidden">
{{ "organizationDataOwnershipWarningContentTop" | i18n }}
<div class="tw-flex tw-flex-col tw-p-2">
<ul class="tw-list-disc tw-pl-5 tw-space-y-2 tw-break-words tw-mb-0">
<li>
{{ "organizationDataOwnershipWarning1" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning2" | i18n }}
</li>
<li>
{{ "organizationDataOwnershipWarning3" | i18n }}
</li>
</ul>
</div>
{{ "organizationDataOwnershipWarningContentBottom" | i18n }}
<a
bitLink
href="https://bitwarden.com/resources/credential-lifecycle-management/"
target="_blank"
>
{{ "organizationDataOwnershipContentAnchor" | i18n }}.
</a>
</div>
</ng-container>
<ng-container bitDialogFooter>
<span class="tw-flex tw-gap-2">
<button bitButton buttonType="primary" [bitDialogClose]="true" type="submit">
{{ "continue" | i18n }}
</button>
<button bitButton buttonType="secondary" [bitDialogClose]="false" type="button">
{{ "cancel" | i18n }}
</button>
</span>
</ng-container>
</bit-simple-dialog>
</ng-template>

View File

@@ -0,0 +1,50 @@
import { Component, OnInit, TemplateRef, ViewChild } from "@angular/core";
import { lastValueFrom, Observable } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { SharedModule } from "../../../shared";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
export class vNextOrganizationDataOwnershipPolicy extends BasePolicy {
name = "organizationDataOwnership";
description = "organizationDataOwnershipDesc";
type = PolicyType.OrganizationDataOwnership;
component = vNextOrganizationDataOwnershipPolicyComponent;
showDescription = false;
override display(organization: Organization, configService: ConfigService): Observable<boolean> {
return configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation);
}
}
@Component({
selector: "vnext-policy-organization-data-ownership",
templateUrl: "vnext-organization-data-ownership.component.html",
standalone: true,
imports: [SharedModule],
})
export class vNextOrganizationDataOwnershipPolicyComponent
extends BasePolicyComponent
implements OnInit
{
constructor(private dialogService: DialogService) {
super();
}
@ViewChild("dialog", { static: true }) warningContent!: TemplateRef<unknown>;
override async confirm(): Promise<boolean> {
if (this.policyResponse?.enabled && !this.enabled.value) {
const dialogRef = this.dialogService.open(this.warningContent);
const result = await lastValueFrom(dialogRef.closed);
return Boolean(result);
}
return true;
}
}

View File

@@ -35,12 +35,14 @@ import {
MasterPasswordPolicy,
PasswordGeneratorPolicy,
OrganizationDataOwnershipPolicy,
vNextOrganizationDataOwnershipPolicy,
RequireSsoPolicy,
ResetPasswordPolicy,
SendOptionsPolicy,
SingleOrgPolicy,
TwoFactorAuthenticationPolicy,
RemoveUnlockWithPinPolicy,
RestrictedItemTypesPolicy,
} from "./admin-console/organizations/policies";
const BroadcasterSubscriptionId = "AppComponent";
@@ -244,8 +246,10 @@ export class AppComponent implements OnDestroy, OnInit {
new SingleOrgPolicy(),
new RequireSsoPolicy(),
new OrganizationDataOwnershipPolicy(),
new vNextOrganizationDataOwnershipPolicy(),
new DisableSendPolicy(),
new SendOptionsPolicy(),
new RestrictedItemTypesPolicy(),
]);
}

View File

@@ -5429,6 +5429,37 @@
"organizationDataOwnership": {
"message": "Enforce organization data ownership"
},
"organizationDataOwnershipDesc": {
"message": "Require all items to be owned by an organization, removing the option to store items at the account level.",
"description": "This is the policy description shown in the policy list."
},
"organizationDataOwnershipContent": {
"message": "All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the ",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'All items will be owned and saved to the organization, enabling organization-wide controls, visibility, and reporting. When turned on, a default collection be available for each member to store items. Learn more about managing the credential lifecycle.'"
},
"organizationDataOwnershipContentAnchor":{
"message": "credential lifecycle",
"description": "This will be used as a hyperlink"
},
"organizationDataOwnershipWarningTitle":{
"message": "Are you sure you want to proceed?"
},
"organizationDataOwnershipWarning1":{
"message": "will remain accessible to members"
},
"organizationDataOwnershipWarning2":{
"message": "will not be automatically selected when creating new items"
},
"organizationDataOwnershipWarning3":{
"message": "cannot be managed from the Admin Console until the user is offboarded"
},
"organizationDataOwnershipWarningContentTop":{
"message": "By turning this policy off, the default collection: "
},
"organizationDataOwnershipWarningContentBottom":{
"message": "Learn more about the ",
"description": "This will be used as part of a larger sentence, broken up to include links. The full sentence will read 'Learn more about the credential lifecycle.'"
},
"personalOwnership": {
"message": "Remove individual vault"
},

View File

@@ -1,7 +1,9 @@
import { Component } from "@angular/core";
import { of } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
BasePolicy,
BasePolicyComponent,
@@ -13,8 +15,8 @@ export class ActivateAutofillPolicy extends BasePolicy {
type = PolicyType.ActivateAutofill;
component = ActivateAutofillPolicyComponent;
display(organization: Organization) {
return organization.useActivateAutofillPolicy;
display(organization: Organization, configService: ConfigService) {
return of(organization.useActivateAutofillPolicy);
}
}

View File

@@ -1,9 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { PolicyType } from "../../enums";
export class PolicyRequest {
export type PolicyRequest = {
type: PolicyType;
enabled: boolean;
data: any;
}
};