From b204bd6560b75e457751d64a34eb02b21c3a0b16 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:14:00 -0700 Subject: [PATCH] initial rework to hoist callout conditional to parent component --- .../export-scope-callout.component.ts | 147 ++++++------------ .../src/components/export.component.html | 1 + .../src/components/export.component.ts | 61 ++++++++ 3 files changed, 108 insertions(+), 101 deletions(-) diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts index e0b4c3a6d6e..e951f74bd31 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts @@ -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(); /* Optional export format, determines which individual export description to display */ readonly exportFormat = input(); + readonly showExcludeMyItems = input(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 { + 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)))) ?? + "", + }; + } + } } diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html index b33b01d3b13..f16e3c92a12 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.html @@ -8,6 +8,7 @@
diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts index f2caf4fe3f4..51dd35b6e57 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export.component.ts @@ -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; + private organizationExport$ = new BehaviorSubject(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); + 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) => {