1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-04 02:33:33 +00:00

initial rework to hoist callout conditional to parent component

This commit is contained in:
John Harrington
2025-09-25 10:14:00 -07:00
parent f367e3d528
commit b204bd6560
3 changed files with 108 additions and 101 deletions

View File

@@ -1,28 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, Optional, input } from "@angular/core";
import { toObservable, takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Component, Optional, effect, input } from "@angular/core";
import { Router } from "@angular/router";
import {
combineLatest,
defer,
distinctUntilChanged,
from,
map,
of,
shareReplay,
filter,
switchMap,
} from "rxjs";
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums/policy-type.enum";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ClientType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { getById } from "@bitwarden/common/platform/misc/rxjs-operators";
import { CalloutModule } from "@bitwarden/components";
@@ -34,7 +21,7 @@ import { CalloutModule } from "@bitwarden/components";
})
export class ExportScopeCalloutComponent {
show = false;
scopeConfig!: {
scopeConfig: {
title: string;
description: string;
scopeIdentifier: string;
@@ -44,96 +31,19 @@ export class ExportScopeCalloutComponent {
readonly organizationId = input<string>();
/* Optional export format, determines which individual export description to display */
readonly exportFormat = input<string>();
readonly showExcludeMyItems = input<boolean>(false);
constructor(
protected organizationService: OrganizationService,
protected accountService: AccountService,
private configService: ConfigService,
private platformUtilsService: PlatformUtilsService,
private policyService: PolicyService,
@Optional() private router?: Router,
) {
const organizationId$ = toObservable(this.organizationId).pipe(distinctUntilChanged());
const exportFormat$ = toObservable(this.exportFormat).pipe(distinctUntilChanged());
const activeAccount$ = this.accountService.activeAccount$;
const userId$ = activeAccount$.pipe(getUserId);
const email$ = activeAccount$.pipe(
map((a) => a?.email ?? ""),
distinctUntilChanged(),
);
const defaultLocationFlag$ = defer(() =>
from(this.configService.getFeatureFlag(FeatureFlag.CreateDefaultLocation)),
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
const orgDataOwnershipEnforced$ = userId$.pipe(
switchMap((userId) =>
userId == null
? of(false)
: this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
),
);
combineLatest({
organizationId: organizationId$,
exportFormat: exportFormat$,
userId: userId$,
email: email$,
defaultLocationFlagEnabled: defaultLocationFlag$,
hasOwnership: orgDataOwnershipEnforced$,
})
.pipe(
// organizationId is null for an individual vault export which actually doesn't require a userId (in the code below)
// if organizationId is set, userId is required to get the org name for the organizational export
filter(({ organizationId, userId }) => organizationId == null || userId != null),
switchMap(
({
organizationId,
exportFormat,
userId,
email,
defaultLocationFlagEnabled,
hasOwnership,
}) => {
const orgExportDescription =
defaultLocationFlagEnabled && hasOwnership
? this.isAdminConsoleContext
? "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc"
: "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc"
: "exportingOrganizationVaultDesc";
if (organizationId != null) {
// exporting from organizational vault
return this.organizationService.organizations$(userId).pipe(
getById(organizationId),
map((org) => ({
title: "exportingOrganizationVaultTitle",
description: orgExportDescription,
scopeIdentifier: org?.name ?? "",
})),
);
}
// exporting from individual vault
const description =
exportFormat === "zip"
? "exportingIndividualVaultWithAttachmentsDescription"
: "exportingIndividualVaultDescription";
return of({
title: "exportingPersonalVaultTitle",
description,
scopeIdentifier: email,
});
},
),
takeUntilDestroyed(),
)
.subscribe((cfg) => {
this.scopeConfig = cfg;
this.show = true;
});
effect(async () => {
this.show = false;
await this.getScopeMessage(this.organizationId(), this.exportFormat());
this.show = true;
});
}
private get isAdminConsoleContext(): boolean {
@@ -148,4 +58,39 @@ export class ExportScopeCalloutComponent {
return false;
}
}
private async getScopeMessage(organizationId: string, exportFormat: string): Promise<void> {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const orgExportDescription = this.showExcludeMyItems
? this.isAdminConsoleContext
? "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc"
: "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc"
: "exportingOrganizationVaultDesc";
if (organizationId != null) {
// exporting from organizational vault
const org = await firstValueFrom(
this.organizationService.organizations$(userId).pipe(getById(organizationId)),
);
this.scopeConfig = {
title: "exportingOrganizationVaultTitle",
description: orgExportDescription,
scopeIdentifier: org?.name ?? "",
};
} else {
this.scopeConfig = {
// exporting from individual vault
title: "exportingPersonalVaultTitle",
description:
exportFormat === "zip"
? "exportingIndividualVaultWithAttachmentsDescription"
: "exportingIndividualVaultDescription",
scopeIdentifier:
(await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email)))) ??
"",
};
}
}
}

View File

@@ -8,6 +8,7 @@
<tools-export-scope-callout
[organizationId]="organizationId"
[exportFormat]="format"
[showExcludeMyItems]="showExcludeMyItems"
></tools-export-scope-callout>
<form [formGroup]="exportForm" [bitSubmit]="submit" id="export_form_exportForm">

View File

@@ -13,11 +13,14 @@ import {
} from "@angular/core";
import { ReactiveFormsModule, UntypedFormBuilder, Validators } from "@angular/forms";
import {
BehaviorSubject,
combineLatest,
firstValueFrom,
from,
map,
merge,
Observable,
shareReplay,
startWith,
Subject,
switchMap,
@@ -37,6 +40,8 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -85,6 +90,8 @@ import { ExportScopeCalloutComponent } from "./export-scope-callout.component";
})
export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
private _organizationId: OrganizationId | undefined;
private createDefaultLocationFlagEnabled$: Observable<boolean>;
private organizationExport$ = new BehaviorSubject<boolean>(false);
get organizationId(): OrganizationId | undefined {
return this._organizationId;
@@ -119,6 +126,33 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
});
}
private _showExcludeMyItems = false;
get showExcludeMyItems(): boolean {
return this._showExcludeMyItems;
}
/**
* When true, the callout will render a translation value specific to org data ownership policy
* Default: false
*
* In order for this to be true, the CreateDefaultLocation feature flag must be enabled,
* the Enforce Organization Data Ownership policy must be enabled,
* and an organization must be selected as input (if organizationId is undefined, it's an individual export).
*/
@Input() set showExcludeMyItems(organizationId: OrganizationId) {
// if organizationId is undefined or invalid, treat as an individual vault export, which is unaffected by policy/flag
const validOrgId = !Utils.isNullOrEmpty(organizationId) && isId<OrganizationId>(organizationId);
if (!validOrgId) {
this._showExcludeMyItems = false;
this.organizationExport$.next(false);
return;
}
// this._organizationId = organizationId; // already assigned via the organizationId input setter
this.organizationExport$.next(true);
}
/**
* The hosting control also needs a bitSubmitDirective (on the Submit button) which calls this components {@link submit}-method.
* This components formState (loading/disabled) is emitted back up to the hosting component so for example the Submit button can be enabled/disabled and show loading state.
@@ -203,9 +237,14 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
protected organizationService: OrganizationService,
private accountService: AccountService,
private collectionService: CollectionService,
private configService: ConfigService,
) {}
async ngOnInit() {
this.createDefaultLocationFlagEnabled$ = from(
this.configService.getFeatureFlag(FeatureFlag.CreateDefaultLocation),
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
// Setup subscription to emit when this form is enabled/disabled
this.exportForm.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((c) => {
this.formDisabled.emit(c === "DISABLED");
@@ -225,6 +264,28 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
),
);
/* determine value of _showExcludeMyItems in a reactive way
feature flag is not expected to change at runtime
policy and organization can be changed by user actions */
combineLatest({
createDefaultLocationFlagEnabled: this.createDefaultLocationFlagEnabled$,
organizationDataOwnershipPolicyEnabled: this.organizationDataOwnershipPolicy$,
hasOrganizationContext: this.organizationExport$,
})
.pipe(takeUntil(this.destroy$))
.subscribe(
({
createDefaultLocationFlagEnabled,
organizationDataOwnershipPolicyEnabled,
hasOrganizationContext,
}) => {
this._showExcludeMyItems =
hasOrganizationContext &&
createDefaultLocationFlagEnabled &&
organizationDataOwnershipPolicyEnabled;
},
);
this.exportForm.controls.vaultSelector.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((value) => {