diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 4e399a530e1..771759d9f25 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3198,6 +3198,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "error": { "message": "Error" }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index a9b5efda357..b2625bc85f0 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2654,6 +2654,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "locked": { "message": "Locked" }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 50b361f3d5a..c6901a21824 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7038,6 +7038,24 @@ } } }, + "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, + "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc": { + "message": "Only the organization vault associated with $ORGANIZATION$ will be exported. My items collections will not be included.", + "placeholders": { + "organization": { + "content": "$1", + "example": "My Org Name" + } + } + }, "accessDenied": { "message": "Access denied. You do not have permission to view this page." }, 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 2b03234c5e2..a85048c23fa 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 @@ -5,12 +5,10 @@ import { Component, effect, input } from "@angular/core"; import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { - getOrganizationById, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { getById } from "@bitwarden/common/platform/misc/rxjs-operators"; import { CalloutModule } from "@bitwarden/components"; @Component({ @@ -30,6 +28,8 @@ export class ExportScopeCalloutComponent { readonly organizationId = input(); /* Optional export format, determines which individual export description to display */ readonly exportFormat = input(); + /* The description key to use for organizational exports */ + readonly orgExportDescription = input(); constructor( protected organizationService: OrganizationService, @@ -37,35 +37,45 @@ export class ExportScopeCalloutComponent { ) { effect(async () => { this.show = false; - await this.getScopeMessage(this.organizationId(), this.exportFormat()); + await this.getScopeMessage( + this.organizationId(), + this.exportFormat(), + this.orgExportDescription(), + ); this.show = true; }); } - private async getScopeMessage(organizationId: string, exportFormat: string): Promise { + private async getScopeMessage( + organizationId: string, + exportFormat: string, + orgExportDescription: string, + ): Promise { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - this.scopeConfig = - organizationId != null - ? { - title: "exportingOrganizationVaultTitle", - description: "exportingOrganizationVaultDesc", - scopeIdentifier: ( - await firstValueFrom( - this.organizationService - .organizations$(userId) - .pipe(getOrganizationById(organizationId)), - ) - ).name, - } - : { - title: "exportingPersonalVaultTitle", - description: - exportFormat == "zip" - ? "exportingIndividualVaultWithAttachmentsDescription" - : "exportingIndividualVaultDescription", - scopeIdentifier: await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.email)), - ), - }; + + 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..c638e5d7dde 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 @@
@@ -19,10 +20,10 @@ [label]="'myVault' | i18n" value="myVault" icon="bwi-user" - *ngIf="!(organizationDataOwnershipPolicy$ | async)" + *ngIf="!(organizationDataOwnershipPolicyAppliesToUser$ | async)" /> (undefined); + private createDefaultLocationFlagEnabled$: Observable; + private _showExcludeMyItems = false; /** * Enables the hosting control to pass in an organizationId @@ -96,29 +103,57 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { */ @Input() set organizationId(value: OrganizationId | string | undefined) { if (Utils.isNullOrEmpty(value)) { - this._organizationId = undefined; + this._organizationId$.next(undefined); return; } if (!isId(value)) { - this._organizationId = undefined; + this._organizationId$.next(undefined); return; } - this._organizationId = value; + this._organizationId$.next(value); getUserId(this.accountService.activeAccount$) .pipe( - switchMap((userId) => - this.organizationService.organizations$(userId).pipe(getById(this._organizationId)), - ), + switchMap((userId) => this.organizationService.organizations$(userId).pipe(getById(value))), ) .pipe(takeUntil(this.destroy$)) .subscribe((organization) => { - this._organizationId = organization?.id; + this._organizationId$.next(organization?.id); }); } + get organizationId(): OrganizationId | undefined { + return this._organizationId$.value; + } + + get showExcludeMyItems(): boolean { + return this._showExcludeMyItems; + } + + get orgExportDescription(): string { + if (!this._showExcludeMyItems) { + return "exportingOrganizationVaultDesc"; + } + return this.isAdminConsoleContext + ? "exportingOrganizationVaultFromAdminConsoleWithDataOwnershipDesc" + : "exportingOrganizationVaultFromPasswordManagerWithDataOwnershipDesc"; + } + + private get isAdminConsoleContext(): boolean { + const isWeb = this.platformUtilsService.getClientType?.() === ClientType.Web; + if (!isWeb || !this.router) { + return false; + } + try { + const url = this.router.url ?? ""; + return url.includes("/organizations/"); + } catch { + return false; + } + } + /** * 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. @@ -143,7 +178,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { /** * Emits when the creation and download of the export-file have succeeded * - Emits an undefined when exporting from an individual vault - * - Emits the organizationId when exporting from an organizationl vault + * - Emits the organizationId when exporting from an organizational vault * */ @Output() onSuccessfulExport = new EventEmitter(); @@ -162,7 +197,10 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { } disablePersonalVaultExportPolicy$: Observable; - organizationDataOwnershipPolicy$: Observable; + // detects if policy is enabled and applies to the user, admins are exempted + organizationDataOwnershipPolicyAppliesToUser$: Observable; + // detects if policy is enabled regardless of admin exemption + organizationDataOwnershipPolicyEnabledForOrg$: Observable; exportForm = this.formBuilder.group({ vaultSelector: [ @@ -203,14 +241,46 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { protected organizationService: OrganizationService, private accountService: AccountService, private collectionService: CollectionService, + private configService: ConfigService, + private platformUtilsService: PlatformUtilsService, + @Optional() private router?: Router, ) {} async ngOnInit() { - // Setup subscription to emit when this form is enabled/disabled + this.observeFeatureFlags(); + this.observeFormState(); + this.observePolicyStatus(); + this.observeFormSelections(); + + // order is important below this line + this.observeMyItemsExclusionCriteria(); + this.observeValidatorAdjustments(); + this.setupPasswordGeneration(); + + if (this.organizationId) { + // organization vault export + this.initOrganizationOnly(); + return; + } + + // individual vault export + this.initIndividual(); + this.setupPolicyBasedFormState(); + } + + private observeFeatureFlags(): void { + this.createDefaultLocationFlagEnabled$ = from( + this.configService.getFeatureFlag(FeatureFlag.CreateDefaultLocation), + ).pipe(shareReplay({ bufferSize: 1, refCount: true })); + } + + private observeFormState(): void { this.exportForm.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((c) => { this.formDisabled.emit(c === "DISABLED"); }); + } + private observePolicyStatus(): void { this.disablePersonalVaultExportPolicy$ = this.accountService.activeAccount$.pipe( getUserId, switchMap((userId) => @@ -218,13 +288,42 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { ), ); - this.organizationDataOwnershipPolicy$ = this.accountService.activeAccount$.pipe( + // when true, html template will hide "My Vault" option in vault selector drop down + this.organizationDataOwnershipPolicyAppliesToUser$ = this.accountService.activeAccount$.pipe( getUserId, switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId), ), ); + /* + Determines how organization exports are described in the callout. + Admins are exempted from organization data ownership policy, + and so this needs to determine if the policy is enabled for the org, not if it applies to the user. + */ + this.organizationDataOwnershipPolicyEnabledForOrg$ = combineLatest([ + this.accountService.activeAccount$.pipe(getUserId), + this._organizationId$, + ]).pipe( + switchMap(([userId, organizationId]) => { + if (!organizationId || !userId) { + return of(false); + } + return this.policyService.policies$(userId).pipe( + map((policies) => { + const policy = policies?.find( + (p) => + p.type === PolicyType.OrganizationDataOwnership && + p.organizationId === organizationId, + ); + return policy?.enabled ?? false; + }), + ); + }), + ); + } + + private observeFormSelections(): void { this.exportForm.controls.vaultSelector.valueChanges .pipe(takeUntil(this.destroy$)) .subscribe((value) => { @@ -236,15 +335,50 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { this.formatOptions.push({ name: ".zip (with attachments)", value: "zip" }); } }); + } + /** + * Determine value of showExcludeMyItems. Returns true when: + * CreateDefaultLocation feature flag is on + * AND organizationDataOwnershipPolicy is enabled for the selected organization + * AND a valid OrganizationId is present (not exporting from individual vault) + */ + private observeMyItemsExclusionCriteria(): void { + combineLatest({ + createDefaultLocationFlagEnabled: this.createDefaultLocationFlagEnabled$, + organizationDataOwnershipPolicyEnabledForOrg: + this.organizationDataOwnershipPolicyEnabledForOrg$, + organizationId: this._organizationId$, + }) + .pipe(takeUntil(this.destroy$)) + .subscribe( + ({ + createDefaultLocationFlagEnabled, + organizationDataOwnershipPolicyEnabledForOrg, + organizationId, + }) => { + if (!createDefaultLocationFlagEnabled || !organizationId) { + this._showExcludeMyItems = false; + return; + } + + this._showExcludeMyItems = organizationDataOwnershipPolicyEnabledForOrg; + }, + ); + } + + // Setup validator adjustments based on format and encryption type changes + private observeValidatorAdjustments(): void { merge( this.exportForm.get("format").valueChanges, this.exportForm.get("fileEncryptionType").valueChanges, ) .pipe(startWith(0), takeUntil(this.destroy$)) .subscribe(() => this.adjustValidators()); + } - // Wire up the password generation for the password-protected export + // Wire up the password generation for password-protected exports + private setupPasswordGeneration(): void { const account$ = this.accountService.activeAccount$.pipe( pin({ name() { @@ -255,6 +389,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { }, }), ); + this.generatorService .generate$({ on$: this.onGenerate$, account$ }) .pipe(takeUntil(this.destroy$)) @@ -264,23 +399,29 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { confirmFilePassword: generated.credential, }); }); + } - if (this.organizationId) { - this.organizations$ = this.accountService.activeAccount$.pipe( - getUserId, - switchMap((userId) => - this.organizationService - .memberOrganizations$(userId) - .pipe(map((orgs) => orgs.filter((org) => org.id == this.organizationId))), - ), - ); - this.exportForm.controls.vaultSelector.patchValue(this.organizationId); - this.exportForm.controls.vaultSelector.disable(); + /* + Initialize component for organization only export + Hides "My Vault" option by returning immediately + */ + private initOrganizationOnly(): void { + this.organizations$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.organizationService + .memberOrganizations$(userId) + .pipe(map((orgs) => orgs.filter((org) => org.id == this.organizationId))), + ), + ); + this.exportForm.controls.vaultSelector.patchValue(this.organizationId); + this.exportForm.controls.vaultSelector.disable(); - this.onlyManagedCollections = false; - return; - } + this.onlyManagedCollections = false; + } + // Initialize component to support individual and organizational exports + private initIndividual(): void { this.organizations$ = this.accountService.activeAccount$ .pipe( getUserId, @@ -296,18 +437,18 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { const managedCollectionsOrgIds = new Set( collections.filter((c) => c.manage).map((c) => c.organizationId), ); - // Filter organizations that exist in managedCollectionsOrgIds const filteredOrgs = memberOrganizations.filter((org) => managedCollectionsOrgIds.has(org.id), ); - // Sort the filtered organizations based on the name return filteredOrgs.sort(Utils.getSortFunction(this.i18nService, "name")); }), ); + } + private setupPolicyBasedFormState(): void { combineLatest([ this.disablePersonalVaultExportPolicy$, - this.organizationDataOwnershipPolicy$, + this.organizationDataOwnershipPolicyAppliesToUser$, this.organizations$, ]) .pipe(