mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-25481] Update copy in Admin-Console export-page (#16594)
* add support for export-scope-callout.component to conditionally render organizational export message • use config service to capture feature flag status • use platform service and routing to determine admin console context
This commit is contained in:
@@ -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<string>();
|
||||
/* Optional export format, determines which individual export description to display */
|
||||
readonly exportFormat = input<string>();
|
||||
/* The description key to use for organizational exports */
|
||||
readonly orgExportDescription = input<string>();
|
||||
|
||||
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<void> {
|
||||
private async getScopeMessage(
|
||||
organizationId: string,
|
||||
exportFormat: string,
|
||||
orgExportDescription: string,
|
||||
): Promise<void> {
|
||||
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)))) ??
|
||||
"",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<tools-export-scope-callout
|
||||
[organizationId]="organizationId"
|
||||
[exportFormat]="format"
|
||||
[orgExportDescription]="orgExportDescription"
|
||||
></tools-export-scope-callout>
|
||||
|
||||
<form [formGroup]="exportForm" [bitSubmit]="submit" id="export_form_exportForm">
|
||||
@@ -19,10 +20,10 @@
|
||||
[label]="'myVault' | i18n"
|
||||
value="myVault"
|
||||
icon="bwi-user"
|
||||
*ngIf="!(organizationDataOwnershipPolicy$ | async)"
|
||||
*ngIf="!(organizationDataOwnershipPolicyAppliesToUser$ | async)"
|
||||
/>
|
||||
<bit-option
|
||||
*ngFor="let o of organizations$ | async"
|
||||
*ngFor="let o of organizations"
|
||||
[value]="o.id"
|
||||
[label]="o.name"
|
||||
icon="bwi-business"
|
||||
|
||||
@@ -10,14 +10,20 @@ import {
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild,
|
||||
Optional,
|
||||
} from "@angular/core";
|
||||
import { ReactiveFormsModule, UntypedFormBuilder, Validators } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
@@ -36,10 +42,13 @@ 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 { EventType } from "@bitwarden/common/enums";
|
||||
import { ClientType, 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";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { pin } from "@bitwarden/common/tools/rx";
|
||||
@@ -84,11 +93,9 @@ import { ExportScopeCalloutComponent } from "./export-scope-callout.component";
|
||||
],
|
||||
})
|
||||
export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
private _organizationId: OrganizationId | undefined;
|
||||
|
||||
get organizationId(): OrganizationId | undefined {
|
||||
return this._organizationId;
|
||||
}
|
||||
private _organizationId$ = new BehaviorSubject<OrganizationId | undefined>(undefined);
|
||||
private createDefaultLocationFlagEnabled$: Observable<boolean>;
|
||||
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<OrganizationId>(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<OrganizationId | undefined>();
|
||||
@@ -162,7 +197,10 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
}
|
||||
|
||||
disablePersonalVaultExportPolicy$: Observable<boolean>;
|
||||
organizationDataOwnershipPolicy$: Observable<boolean>;
|
||||
// detects if policy is enabled and applies to the user, admins are exempted
|
||||
organizationDataOwnershipPolicyAppliesToUser$: Observable<boolean>;
|
||||
// detects if policy is enabled regardless of admin exemption
|
||||
organizationDataOwnershipPolicyEnabledForOrg$: Observable<boolean>;
|
||||
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user