From e333c0a8bcc6735bb8705d4ee5e334de11446e6a Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Wed, 29 Oct 2025 12:49:31 -0700 Subject: [PATCH] Preserve export type across export source selections (#16922) --- .../vault-export.service.abstraction.ts | 27 +++++++++++ .../src/services/vault-export.service.ts | 29 +++++++++++- .../src/components/export.component.html | 2 +- .../src/components/export.component.ts | 45 ++++++++++++------- 4 files changed, 85 insertions(+), 18 deletions(-) diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts index e25fec6eb82..0d58f168671 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.abstraction.ts @@ -1,3 +1,5 @@ +import { Observable } from "rxjs"; + import { UserId, OrganizationId } from "@bitwarden/common/types/guid"; import { ExportedVault } from "../types"; @@ -5,6 +7,24 @@ import { ExportedVault } from "../types"; export const EXPORT_FORMATS = ["csv", "json", "encrypted_json", "zip"] as const; export type ExportFormat = (typeof EXPORT_FORMATS)[number]; +/** + * Options that determine which export formats are available + */ +export type FormatOptions = { + /** Whether the export is for the user's personal vault */ + isMyVault: boolean; +}; + +/** + * Metadata describing an available export format + */ +export type ExportFormatMetadata = { + /** Display name for the format (e.g., ".json", ".csv") */ + name: string; + /** The export format identifier */ + format: ExportFormat; +}; + export abstract class VaultExportServiceAbstraction { abstract getExport: ( userId: UserId, @@ -18,4 +38,11 @@ export abstract class VaultExportServiceAbstraction { password: string, onlyManagedCollections?: boolean, ) => Promise; + + /** + * Get available export formats based on vault context + * @param options Options determining which formats are available + * @returns Observable stream of available export formats + */ + abstract formats$(options: FormatOptions): Observable; } diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts index b601478d06d..38d71136006 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Observable, of } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; @@ -9,7 +9,12 @@ import { ExportedVault } from "../types"; import { IndividualVaultExportServiceAbstraction } from "./individual-vault-export.service.abstraction"; import { OrganizationVaultExportServiceAbstraction } from "./org-vault-export.service.abstraction"; -import { ExportFormat, VaultExportServiceAbstraction } from "./vault-export.service.abstraction"; +import { + ExportFormat, + ExportFormatMetadata, + FormatOptions, + VaultExportServiceAbstraction, +} from "./vault-export.service.abstraction"; export class VaultExportService implements VaultExportServiceAbstraction { constructor( @@ -85,6 +90,26 @@ export class VaultExportService implements VaultExportServiceAbstraction { ); } + /** + * Get available export formats based on vault context + * @param options Options determining which formats are available + * @returns Observable stream of available export formats + */ + formats$(options: FormatOptions): Observable { + const baseFormats: ExportFormatMetadata[] = [ + { name: ".json", format: "json" }, + { name: ".csv", format: "csv" }, + { name: ".json (Encrypted)", format: "encrypted_json" }, + ]; + + // ZIP format with attachments is only available for individual vault exports + if (options.isMyVault) { + return of([...baseFormats, { name: ".zip (with attachments)", format: "zip" }]); + } + + return of(baseFormats); + } + /** Checks if the provided userId matches the currently authenticated user * @param userId The userId to check * @throws Error if the userId does not match the currently authenticated user 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 c638e5d7dde..f41375edd5a 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 @@ -35,7 +35,7 @@ {{ "fileFormat" | i18n }} - + 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 19921b35162..610f30c1f67 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 @@ -67,7 +67,11 @@ import { } from "@bitwarden/components"; import { GeneratorServicesModule } from "@bitwarden/generator-components"; import { CredentialGeneratorService, GenerateRequest, Type } from "@bitwarden/generator-core"; -import { ExportedVault, VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; +import { + ExportedVault, + ExportFormatMetadata, + VaultExportServiceAbstraction, +} from "@bitwarden/vault-export-core"; import { EncryptedExportType } from "../enums/encrypted-export-type.enum"; @@ -231,11 +235,11 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { fileEncryptionType: [EncryptedExportType.AccountEncrypted], }); - formatOptions = [ - { name: ".json", value: "json" }, - { name: ".csv", value: "csv" }, - { name: ".json (Encrypted)", value: "encrypted_json" }, - ]; + /** + * Observable stream of available export format options + * Dynamically updates based on vault selection (My Vault vs Organization) + */ + formatOptions$: Observable; private destroy$ = new Subject(); private onlyManagedCollections = true; @@ -338,17 +342,28 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit { } private observeFormSelections(): void { - this.exportForm.controls.vaultSelector.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe((value) => { - this.organizationId = value !== "myVault" ? value : undefined; + // Set up dynamic format options based on vault selection + this.formatOptions$ = this.exportForm.controls.vaultSelector.valueChanges.pipe( + startWith(this.exportForm.controls.vaultSelector.value), + map((vaultSelection) => { + const isMyVault = vaultSelection === "myVault"; + // Update organizationId based on vault selection + this.organizationId = isMyVault ? undefined : vaultSelection; + return { isMyVault }; + }), + switchMap((options) => this.exportService.formats$(options)), + tap((formats) => { + // Preserve the current format selection if it's still available in the new format list + const currentFormat = this.exportForm.get("format").value; + const isFormatAvailable = formats.some((f) => f.format === currentFormat); - this.formatOptions = this.formatOptions.filter((option) => option.value !== "zip"); - this.exportForm.get("format").setValue("json"); - if (value === "myVault") { - this.formatOptions.push({ name: ".zip (with attachments)", value: "zip" }); + // Only reset to json if the current format is no longer available + if (!isFormatAvailable) { + this.exportForm.get("format").setValue("json"); } - }); + }), + shareReplay({ bufferSize: 1, refCount: true }), + ); } /**