1
0
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:
John Harrington
2025-10-02 06:20:21 -07:00
committed by GitHub
parent 4313dd2ecb
commit 65d56ca2f3
6 changed files with 272 additions and 66 deletions

View File

@@ -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)))) ??
"",
};
}
}
}

View File

@@ -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"

View File

@@ -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(