1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-17 08:43:33 +00:00

Merge branch 'main' into auth/pm-8111/browser-refresh-login-component

This commit is contained in:
Alec Rippberger
2024-10-03 09:31:54 -05:00
committed by GitHub
49 changed files with 1368 additions and 395 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2024.9.2",
"version": "2024.10.0",
"scripts": {
"build": "cross-env MANIFEST_VERSION=3 webpack",
"build:mv2": "webpack",

View File

@@ -283,7 +283,7 @@ export default class MainBackground {
folderService: InternalFolderServiceAbstraction;
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction;
collectionService: CollectionServiceAbstraction;
vaultTimeoutService: VaultTimeoutService;
vaultTimeoutService?: VaultTimeoutService;
vaultTimeoutSettingsService: VaultTimeoutSettingsServiceAbstraction;
passwordGenerationService: PasswordGenerationServiceAbstraction;
syncService: SyncService;
@@ -842,24 +842,26 @@ export default class MainBackground {
this.vaultSettingsService = new VaultSettingsService(this.stateProvider);
this.vaultTimeoutService = new VaultTimeoutService(
this.accountService,
this.masterPasswordService,
this.cipherService,
this.folderService,
this.collectionService,
this.platformUtilsService,
this.messagingService,
this.searchService,
this.stateService,
this.authService,
this.vaultTimeoutSettingsService,
this.stateEventRunnerService,
this.taskSchedulerService,
this.logService,
lockedCallback,
logoutCallback,
);
if (!this.popupOnlyContext) {
this.vaultTimeoutService = new VaultTimeoutService(
this.accountService,
this.masterPasswordService,
this.cipherService,
this.folderService,
this.collectionService,
this.platformUtilsService,
this.messagingService,
this.searchService,
this.stateService,
this.authService,
this.vaultTimeoutSettingsService,
this.stateEventRunnerService,
this.taskSchedulerService,
this.logService,
lockedCallback,
logoutCallback,
);
}
this.containerService = new ContainerService(this.cryptoService, this.encryptService);
this.sendStateProvider = new SendStateProvider(this.stateProvider);

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2024.9.2",
"version": "2024.10.0",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2024.9.2",
"version": "2024.10.0",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@@ -56,7 +56,7 @@ export class PopupTabNavigationComponent {
.pipe(
filter((policyAppliesToActiveUser) => policyAppliesToActiveUser),
switchMap(() => this.sendService.sends$),
map((sends) => sends.length > 1),
map((sends) => sends.length > 0),
takeUntilDestroyed(),
)
.subscribe((hasSends) => {

View File

@@ -7,14 +7,15 @@
</bit-section-header>
<bit-item-group>
<bit-item *ngFor="let cipher of ciphers">
<a
<button
bit-item-content
type="button"
[appA11yTitle]="'viewItemTitle' | i18n: cipher.name"
(click)="onViewCipher(cipher)"
>
<app-vault-icon slot="start" [cipher]="cipher"></app-vault-icon>
<span data-testid="item-name">{{ cipher.name }}</span>
</a>
</button>
<ng-container slot="end" *ngIf="cipher.edit">
<bit-item-action>
<button

View File

@@ -59,7 +59,7 @@
"@koa/multer": "3.0.2",
"@koa/router": "12.0.1",
"argon2": "0.40.1",
"big-integer": "1.6.51",
"big-integer": "1.6.52",
"browser-hrtime": "1.1.8",
"chalk": "4.1.2",
"commander": "11.1.0",

View File

@@ -3,7 +3,7 @@
/* auto-generated by NAPI-RS */
export namespace passwords {
export declare namespace passwords {
/** Fetch the stored password from the keychain. */
export function getPassword(service: string, account: string): Promise<string>
/** Fetch the stored password from the keychain that was stored with Keytar. */
@@ -14,7 +14,7 @@ export namespace passwords {
export function deletePassword(service: string, account: string): Promise<void>
export function isAvailable(): Promise<boolean>
}
export namespace biometrics {
export declare namespace biometrics {
export function prompt(hwnd: Buffer, message: string): Promise<boolean>
export function available(): Promise<boolean>
export function setBiometricSecret(service: string, account: string, secret: string, keyMaterial: KeyMaterial | undefined | null, ivB64: string): Promise<string>
@@ -38,16 +38,16 @@ export namespace biometrics {
ivB64: string
}
}
export namespace clipboards {
export declare namespace clipboards {
export function read(): Promise<string>
export function write(text: string, password: boolean): Promise<void>
}
export namespace processisolations {
export declare namespace processisolations {
export function disableCoredumps(): Promise<void>
export function isCoreDumpingDisabled(): Promise<boolean>
export function disableMemoryAccess(): Promise<void>
}
export namespace powermonitors {
export declare namespace powermonitors {
export function onLock(callback: (err: Error | null, ) => any): Promise<void>
export function isLockMonitorAvailable(): Promise<boolean>
}

View File

@@ -9,7 +9,7 @@
"author": "",
"license": "GPL-3.0",
"devDependencies": {
"@napi-rs/cli": "2.16.2"
"@napi-rs/cli": "2.18.4"
},
"napi": {
"name": "desktop_napi",

View File

@@ -21,6 +21,13 @@
"@napi-rs/cli": "2.16.2"
}
},
"../desktop_native/napi": {
"version": "0.1.0",
"license": "GPL-3.0",
"devDependencies": {
"@napi-rs/cli": "2.18.4"
}
},
"node_modules/@bitwarden/desktop-napi": {
"resolved": "../desktop_native/napi",
"link": true

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2024.9.2",
"version": "2024.10.0",
"scripts": {
"build:oss": "webpack",
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@@ -1,5 +1,6 @@
import { CommonModule } from "@angular/common";
import { APP_INITIALIZER, NgModule, Optional, SkipSelf } from "@angular/core";
import { Router } from "@angular/router";
import {
CollectionAdminService,
@@ -184,7 +185,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: EnvironmentService,
useClass: WebEnvironmentService,
deps: [WINDOW, StateProvider, AccountService],
deps: [WINDOW, StateProvider, AccountService, Router],
}),
safeProvider({
provide: BiometricsService,

View File

@@ -1,3 +1,4 @@
import { Router } from "@angular/router";
import { ReplaySubject } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -23,6 +24,7 @@ export class WebEnvironmentService extends DefaultEnvironmentService {
private win: Window,
stateProvider: StateProvider,
accountService: AccountService,
private router: Router,
) {
super(stateProvider, accountService);
@@ -47,9 +49,34 @@ export class WebEnvironmentService extends DefaultEnvironmentService {
this.environment$ = subject.asObservable();
}
// Web cannot set environment
async setEnvironment(region: Region, urls?: Urls): Promise<Urls> {
return;
// Web setting env means navigating to a new location
setEnvironment(region: Region, urls?: Urls): Promise<Urls> {
if (region === Region.SelfHosted) {
throw new Error("setEnvironment does not work in web for self-hosted.");
}
const currentDomain = Utils.getDomain(this.win.location.href);
const currentRegion = this.availableRegions().find(
(r) => Utils.getDomain(r.urls.webVault) === currentDomain,
);
if (currentRegion.key === region) {
// They have selected the current region, nothing to do
return Promise.resolve(currentRegion.urls);
}
const chosenRegion = this.availableRegions().find((r) => r.key === region);
if (chosenRegion == null) {
throw new Error("The selected region is not known as an available region.");
}
// Preserve the current in app route + params in the new location
const routeAndParams = `/#${this.router.url}`;
this.win.location.href = chosenRegion.urls.webVault + routeAndParams;
// This return shouldn't matter as we are about to leave the current window
return Promise.resolve(chosenRegion.urls);
}
}

View File

@@ -0,0 +1,79 @@
<bit-dialog dialogSize="large" background="alt" [loading]="performingInitialLoad">
<span bitDialogTitle aria-live="polite">
{{ title }}
</span>
<div bitDialogContent #dialogContent>
<app-cipher-view
*ngIf="showCipherView"
[cipher]="cipher"
[collections]="collections"
></app-cipher-view>
<vault-cipher-form
*ngIf="loadForm"
formId="cipherForm"
[config]="formConfig"
[submitBtn]="submitBtn"
(formReady)="onFormReady()"
(cipherSaved)="onCipherSaved($event)"
>
<bit-item slot="attachment-button">
<button bit-item-content type="button" (click)="openAttachmentsDialog()">
<p class="tw-m-0">
{{ "attachments" | i18n }}
<span
*ngIf="!(canAccessAttachments$ | async)"
bitBadge
variant="success"
class="tw-ml-2"
>
{{ "premium" | i18n }}
</span>
</p>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
</div>
<ng-container bitDialogFooter>
<ng-container *ngIf="showCipherView">
<button
bitButton
[bitAction]="switchToEdit"
buttonType="primary"
type="button"
[disabled]="disableEdit"
>
{{ "edit" | i18n }}
</button>
</ng-container>
<button
bitButton
type="submit"
form="cipherForm"
buttonType="primary"
#submitBtn
[hidden]="showCipherView"
>
{{ "save" | i18n }}
</button>
<button
bitButton
type="button"
buttonType="secondary"
(click)="cancel()"
*ngIf="!showCipherView"
>
{{ "cancel" | i18n }}
</button>
<div class="tw-ml-auto">
<button
bitIconButton="bwi-trash"
type="button"
buttonType="danger"
[appA11yTitle]="'delete' | i18n"
[bitAction]="delete"
[disabled]="!canDelete"
></button>
</div>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,436 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, Subject } from "rxjs";
import { map } from "rxjs/operators";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
import {
AsyncActionsModule,
ButtonModule,
DialogModule,
DialogService,
ItemModule,
ToastService,
} from "@bitwarden/components";
import {
CipherAttachmentsComponent,
CipherFormConfig,
CipherFormGenerationService,
CipherFormModule,
CipherViewComponent,
} from "@bitwarden/vault";
import { SharedModule } from "../../../shared/shared.module";
import {
AttachmentDialogCloseResult,
AttachmentDialogResult,
AttachmentsV2Component,
} from "../../individual-vault/attachments-v2.component";
import { WebCipherFormGenerationService } from "../../services/web-cipher-form-generation.service";
import { WebVaultPremiumUpgradePromptService } from "../../services/web-premium-upgrade-prompt.service";
import { WebViewPasswordHistoryService } from "../../services/web-view-password-history.service";
export type VaultItemDialogMode = "view" | "form";
export interface VaultItemDialogParams {
/**
* The mode of the dialog.
* - `view` is for viewing an existing cipher.
* - `form` is for editing or creating a new cipher.
*/
mode: VaultItemDialogMode;
/**
* The configuration object for the dialog and form.
*/
formConfig: CipherFormConfig;
/**
* If true, the "edit" button will be disabled in the dialog.
*/
disableForm?: boolean;
}
export enum VaultItemDialogResult {
/**
* A cipher was saved (created or updated).
*/
Saved = "saved",
/**
* A cipher was deleted.
*/
Deleted = "deleted",
/**
* The dialog was closed to navigate the user the premium upgrade page.
*/
PremiumUpgrade = "premiumUpgrade",
}
@Component({
selector: "app-vault-item-dialog",
templateUrl: "vault-item-dialog.component.html",
standalone: true,
imports: [
ButtonModule,
CipherViewComponent,
DialogModule,
CommonModule,
SharedModule,
CipherFormModule,
CipherAttachmentsComponent,
AsyncActionsModule,
ItemModule,
],
providers: [
{ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService },
{ provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService },
{ provide: CipherFormGenerationService, useClass: WebCipherFormGenerationService },
],
})
export class VaultItemDialogComponent implements OnInit, OnDestroy {
/**
* Reference to the dialog content element. Used to scroll to the top of the dialog when switching modes.
* @protected
*/
@ViewChild("dialogContent")
protected dialogContent: ElementRef<HTMLElement>;
/**
* Tracks if the cipher was ever modified while the dialog was open. Used to ensure the dialog emits the correct result
* in case of closing with the X button or ESC key.
* @private
*/
private _cipherModified: boolean = false;
/**
* The original mode of the form when the dialog is first opened.
* Used to determine if the form should switch to edit mode after successfully creating a new cipher.
* @private
*/
private _originalFormMode = this.params.formConfig.mode;
/**
* Subject to emit when the form is ready to be displayed.
* @private
*/
private _formReadySubject = new Subject<void>();
/**
* Tracks if the dialog is performing the initial load. Used to display a spinner while loading.
* @private
*/
protected performingInitialLoad: boolean = true;
/**
* The title of the dialog. Updates based on the dialog mode and cipher type.
* @protected
*/
protected title: string;
/**
* The current cipher being viewed. Undefined if creating a new cipher.
* @protected
*/
protected cipher?: CipherView;
/**
* The organization the current cipher belongs to. Undefined if creating a new cipher.
* @protected
*/
protected organization?: Organization;
/**
* The collections the current cipher is assigned to. Undefined if creating a new cipher.
* @protected
*/
protected collections?: CollectionView[];
/**
* Flag to indicate if the user has access to attachments via a premium subscription.
* @protected
*/
protected canAccessAttachments$ = this.billingAccountProfileStateService.hasPremiumFromAnySource$;
protected get loadingForm() {
return this.loadForm && !this.formReady;
}
protected get disableEdit() {
return this.params.disableForm;
}
protected get canDelete() {
return this.cipher?.edit ?? false;
}
protected get showCipherView() {
return this.cipher != undefined && (this.params.mode === "view" || this.loadingForm);
}
/**
* Flag to initialize/attach the form component.
*/
protected loadForm = this.params.mode === "form";
/**
* Flag to indicate the form is ready to be displayed.
*/
protected formReady = false;
protected formConfig: CipherFormConfig = this.params.formConfig;
constructor(
@Inject(DIALOG_DATA) protected params: VaultItemDialogParams,
private dialogRef: DialogRef<VaultItemDialogResult>,
private dialogService: DialogService,
private i18nService: I18nService,
private toastService: ToastService,
private messagingService: MessagingService,
private logService: LogService,
private cipherService: CipherService,
private accountService: AccountService,
private router: Router,
private billingAccountProfileStateService: BillingAccountProfileStateService,
) {
this.updateTitle();
}
async ngOnInit() {
this.cipher = await this.getDecryptedCipherView(this.formConfig);
if (this.cipher) {
this.collections = this.formConfig.collections.filter((c) =>
this.cipher.collectionIds?.includes(c.id),
);
this.organization = this.formConfig.organizations.find(
(o) => o.id === this.cipher.organizationId,
);
}
this.performingInitialLoad = false;
}
ngOnDestroy() {
// If the cipher was modified, be sure we emit the saved result in case the dialog was closed with the X button or ESC key.
if (this._cipherModified) {
this.dialogRef.close(VaultItemDialogResult.Saved);
}
}
/**
* Called by the CipherFormComponent when the cipher is saved successfully.
* @param cipherView - The newly saved cipher.
*/
protected async onCipherSaved(cipherView: CipherView) {
// We successfully saved the cipher, update the dialog state and switch to view mode.
this.cipher = cipherView;
this.collections = this.formConfig.collections.filter((c) =>
cipherView.collectionIds?.includes(c.id),
);
// If the cipher was newly created (via add/clone), switch the form to edit for subsequent edits.
if (this._originalFormMode === "add" || this._originalFormMode === "clone") {
this.formConfig.mode = "edit";
}
this.formConfig.originalCipher = await this.cipherService.get(cipherView.id);
this._cipherModified = true;
await this.changeMode("view");
}
/**
* Called by the CipherFormComponent when the form is ready to be displayed.
*/
protected onFormReady() {
this.formReady = true;
this._formReadySubject.next();
}
delete = async () => {
if (!this.cipher) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: {
key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation",
},
type: "warning",
});
if (!confirmed) {
return;
}
try {
await this.deleteCipher();
this.toastService.showToast({
variant: "success",
title: this.i18nService.t("success"),
message: this.i18nService.t(
this.cipher.isDeleted ? "permanentlyDeletedItem" : "deletedItem",
),
});
this.messagingService.send(
this.cipher.isDeleted ? "permanentlyDeletedCipher" : "deletedCipher",
);
} catch (e) {
this.logService.error(e);
}
this._cipherModified = false;
this.dialogRef.close(VaultItemDialogResult.Deleted);
};
openAttachmentsDialog = async () => {
const dialogRef = this.dialogService.open<AttachmentDialogCloseResult, { cipherId: CipherId }>(
AttachmentsV2Component,
{
data: {
cipherId: this.formConfig.originalCipher?.id as CipherId,
},
},
);
const result = await firstValueFrom(dialogRef.closed);
if (
result.action === AttachmentDialogResult.Removed ||
result.action === AttachmentDialogResult.Uploaded
) {
this._cipherModified = true;
}
};
switchToEdit = async () => {
if (!this.cipher) {
return;
}
await this.changeMode("form");
};
cancel = async () => {
// We're in View mode, or we don't have a cipher, close the dialog.
if (this.params.mode === "view" || this.cipher == null) {
this.dialogRef.close(this._cipherModified ? VaultItemDialogResult.Saved : undefined);
return;
}
// We're in Form mode, and we have a cipher, switch back to View mode.
await this.changeMode("view");
};
private async getDecryptedCipherView(config: CipherFormConfig) {
if (config.originalCipher == null) {
return;
}
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
return await config.originalCipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(config.originalCipher, activeUserId),
);
}
private updateTitle() {
let partOne: string;
if (this.params.mode === "view") {
partOne = "viewItemType";
} else if (this.formConfig.mode === "edit" || this.formConfig.mode === "partial-edit") {
partOne = "editItemHeader";
} else {
partOne = "newItemHeader";
}
const type = this.cipher?.type ?? this.formConfig.cipherType ?? CipherType.Login;
switch (type) {
case CipherType.Login:
this.title = this.i18nService.t(partOne, this.i18nService.t("typeLogin").toLowerCase());
break;
case CipherType.Card:
this.title = this.i18nService.t(partOne, this.i18nService.t("typeCard").toLowerCase());
break;
case CipherType.Identity:
this.title = this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLowerCase());
break;
case CipherType.SecureNote:
this.title = this.i18nService.t(partOne, this.i18nService.t("note").toLowerCase());
break;
}
}
/**
* Changes the mode of the dialog. When switching to Form mode, the form is initialized first then displayed once ready.
* @param mode
* @private
*/
private async changeMode(mode: VaultItemDialogMode) {
this.formReady = false;
if (mode == "form") {
this.loadForm = true;
// Wait for the formReadySubject to emit before continuing.
// This helps prevent flashing an empty dialog while the form is initializing.
await firstValueFrom(this._formReadySubject);
} else {
this.loadForm = false;
}
this.params.mode = mode;
this.updateTitle();
// Scroll to the top of the dialog content when switching modes.
this.dialogContent.nativeElement.parentElement.scrollTop = 0;
// Update the URL query params to reflect the new mode.
await this.router.navigate([], {
queryParams: {
action: mode === "form" ? "edit" : "view",
itemId: this.cipher?.id,
},
queryParamsHandling: "merge",
replaceUrl: true,
});
}
/**
* Helper method to delete cipher.
*/
private async deleteCipher(): Promise<void> {
const asAdmin = this.organization?.canEditAllCiphers;
if (this.cipher.isDeleted) {
await this.cipherService.deleteWithServer(this.cipher.id, asAdmin);
} else {
await this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin);
}
}
/**
* Opens the VaultItemDialog.
* @param dialogService
* @param params
*/
static open(dialogService: DialogService, params: VaultItemDialogParams) {
return dialogService.open<VaultItemDialogResult, VaultItemDialogParams>(
VaultItemDialogComponent,
{
data: params,
},
);
}
}

View File

@@ -106,12 +106,7 @@
</a>
</ng-container>
<button
bitMenuItem
*ngIf="showAttachments || !vaultBulkManagementActionEnabled"
type="button"
(click)="attachments()"
>
<button bitMenuItem *ngIf="showAttachments" type="button" (click)="attachments()">
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
{{ "attachments" | i18n }}
</button>
@@ -119,26 +114,6 @@
<i class="bwi bwi-fw bwi-files" aria-hidden="true"></i>
{{ "clone" | i18n }}
</button>
<!-- This option will be phased out in future releases -->
<button
bitMenuItem
*ngIf="!cipher.organizationId && !cipher.isDeleted && !vaultBulkManagementActionEnabled"
type="button"
(click)="moveToOrganization()"
>
<i class="bwi bwi-fw bwi-arrow-circle-right" aria-hidden="true"></i>
{{ "moveToOrganization" | i18n }}
</button>
<!-- This option will be phased out in future releases -->
<button
bitMenuItem
*ngIf="cipher.organizationId && !cipher.isDeleted && !vaultBulkManagementActionEnabled"
type="button"
(click)="editCollections()"
>
<i class="bwi bwi-fw bwi-collection" aria-hidden="true"></i>
{{ "collections" | i18n }}
</button>
<button
bitMenuItem
*ngIf="showAssignToCollections"
@@ -156,12 +131,7 @@
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restore" | i18n }}
</button>
<button
bitMenuItem
*ngIf="canEditCipher || !vaultBulkManagementActionEnabled"
(click)="deleteCipher()"
type="button"
>
<button bitMenuItem *ngIf="canEditCipher" (click)="deleteCipher()" type="button">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ (cipher.isDeleted ? "permanentlyDelete" : "delete") | i18n }}

View File

@@ -35,7 +35,6 @@ export class VaultCipherRowComponent implements OnInit {
@Input() collections: CollectionView[];
@Input() viewingOrgVault: boolean;
@Input() canEditCipher: boolean;
@Input() vaultBulkManagementActionEnabled: boolean;
@Output() onEvent = new EventEmitter<VaultItemEvent>();
@@ -100,17 +99,15 @@ export class VaultCipherRowComponent implements OnInit {
}
protected get disableMenu() {
return (
!(
this.isNotDeletedLoginCipher ||
this.showCopyPassword ||
this.showCopyTotp ||
this.showLaunchUri ||
this.showAttachments ||
this.showClone ||
this.canEditCipher ||
this.cipher.isDeleted
) && this.vaultBulkManagementActionEnabled
return !(
this.isNotDeletedLoginCipher ||
this.showCopyPassword ||
this.showCopyTotp ||
this.showLaunchUri ||
this.showAttachments ||
this.showClone ||
this.canEditCipher ||
this.cipher.isDeleted
);
}
@@ -122,14 +119,6 @@ export class VaultCipherRowComponent implements OnInit {
this.onEvent.emit({ type: "clone", item: this.cipher });
}
protected moveToOrganization() {
this.onEvent.emit({ type: "moveToOrganization", items: [this.cipher] });
}
protected editCollections() {
this.onEvent.emit({ type: "viewCipherCollections", item: this.cipher });
}
protected events() {
this.onEvent.emit({ type: "viewEvents", item: this.cipher });
}

View File

@@ -5,7 +5,6 @@ import { VaultItem } from "./vault-item";
export type VaultItemEvent =
| { type: "viewAttachments"; item: CipherView }
| { type: "viewCipherCollections"; item: CipherView }
| { type: "bulkEditCollectionAccess"; items: CollectionView[] }
| { type: "viewCollectionAccess"; item: CollectionView; readonly: boolean }
| { type: "viewEvents"; item: CipherView }
@@ -15,5 +14,4 @@ export type VaultItemEvent =
| { type: "delete"; items: VaultItem[] }
| { type: "copyField"; item: CipherView; field: "username" | "password" | "totp" }
| { type: "moveToFolder"; items: CipherView[] }
| { type: "moveToOrganization"; items: CipherView[] }
| { type: "assignToCollections"; items: CipherView[] };

View File

@@ -38,7 +38,7 @@
<bit-menu #headerMenu>
<button *ngIf="bulkMoveAllowed" type="button" bitMenuItem (click)="bulkMoveToFolder()">
<i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i>
{{ (vaultBulkManagementActionEnabled ? "addToFolder" : "moveSelected") | i18n }}
{{ "addToFolder" | i18n }}
</button>
<button
*ngIf="showAdminActions && showBulkEditCollectionAccess"
@@ -60,21 +60,12 @@
<i class="bwi bwi-fw bwi-collection" aria-hidden="true"></i>
{{ "assignToCollections" | i18n }}
</button>
<button
*ngIf="bulkMoveAllowed && !vaultBulkManagementActionEnabled"
type="button"
bitMenuItem
(click)="bulkMoveToOrganization()"
>
<i class="bwi bwi-fw bwi-arrow-circle-right" aria-hidden="true"></i>
{{ "moveSelectedToOrg" | i18n }}
</button>
<button *ngIf="showBulkTrashOptions" type="button" bitMenuItem (click)="bulkRestore()">
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restoreSelected" | i18n }}
</button>
<button
*ngIf="deleteAllowed || showBulkTrashOptions"
*ngIf="showDelete() || showBulkTrashOptions"
type="button"
bitMenuItem
(click)="bulkDelete()"
@@ -131,8 +122,7 @@
[organizations]="allOrganizations"
[collections]="allCollections"
[checked]="selection.isSelected(item)"
[canEditCipher]="canEditCipher(item.cipher) && vaultBulkManagementActionEnabled"
[vaultBulkManagementActionEnabled]="vaultBulkManagementActionEnabled"
[canEditCipher]="canEditCipher(item.cipher)"
(checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)"
></tr>

View File

@@ -46,7 +46,6 @@ export class VaultItemsComponent {
@Input() viewingOrgVault: boolean;
@Input() addAccessStatus: number;
@Input() addAccessToggle: boolean;
@Input() vaultBulkManagementActionEnabled = false;
private _ciphers?: CipherView[] = [];
@Input() get ciphers(): CipherView[] {
@@ -93,23 +92,13 @@ export class VaultItemsComponent {
}
get disableMenu() {
return (
this.vaultBulkManagementActionEnabled &&
!this.bulkMoveAllowed &&
!this.showAssignToCollections() &&
!this.showDelete()
);
return !this.bulkMoveAllowed && !this.showAssignToCollections() && !this.showDelete();
}
get bulkAssignToCollectionsAllowed() {
return this.showBulkAddToCollections && this.ciphers.length > 0;
}
// Use new bulk management delete if vaultBulkManagementActionEnabled feature flag is enabled
get deleteAllowed() {
return this.vaultBulkManagementActionEnabled ? this.showDelete() : true;
}
protected canEditCollection(collection: CollectionView): boolean {
// Only allow allow deletion if collection editing is enabled and not deleting "Unassigned"
if (collection.id === Unassigned) {
@@ -156,15 +145,6 @@ export class VaultItemsComponent {
});
}
protected bulkMoveToOrganization() {
this.event({
type: "moveToOrganization",
items: this.selection.selected
.filter((item) => item.cipher !== undefined)
.map((item) => item.cipher),
});
}
protected bulkRestore() {
this.event({
type: "restore",

View File

@@ -0,0 +1,22 @@
<bit-dialog dialogSize="default">
<span bitDialogTitle>
{{ title }}
</span>
<ng-container bitDialogContent>
<vault-cipher-form-generator
[type]="params.type"
(valueGenerated)="onValueGenerated($event)"
></vault-cipher-form-generator>
</ng-container>
<ng-container bitDialogFooter>
<button
type="button"
bitButton
buttonType="primary"
(click)="selectValue()"
data-testid="select-button"
>
{{ selectButtonText }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -0,0 +1,126 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
PasswordGenerationServiceAbstraction,
UsernameGenerationServiceAbstraction,
} from "@bitwarden/generator-legacy";
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
import {
WebVaultGeneratorDialogAction,
WebVaultGeneratorDialogComponent,
WebVaultGeneratorDialogParams,
} from "./web-generator-dialog.component";
describe("WebVaultGeneratorDialogComponent", () => {
let component: WebVaultGeneratorDialogComponent;
let fixture: ComponentFixture<WebVaultGeneratorDialogComponent>;
let dialogRef: MockProxy<DialogRef<any>>;
let mockI18nService: MockProxy<I18nService>;
let passwordOptionsSubject: BehaviorSubject<any>;
let usernameOptionsSubject: BehaviorSubject<any>;
let mockPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
let mockUsernameGenerationService: MockProxy<UsernameGenerationServiceAbstraction>;
beforeEach(async () => {
dialogRef = mock<DialogRef<any>>();
mockI18nService = mock<I18nService>();
passwordOptionsSubject = new BehaviorSubject([{ type: "password" }]);
usernameOptionsSubject = new BehaviorSubject([{ type: "username" }]);
mockPasswordGenerationService = mock<PasswordGenerationServiceAbstraction>();
mockPasswordGenerationService.getOptions$.mockReturnValue(
passwordOptionsSubject.asObservable(),
);
mockUsernameGenerationService = mock<UsernameGenerationServiceAbstraction>();
mockUsernameGenerationService.getOptions$.mockReturnValue(
usernameOptionsSubject.asObservable(),
);
const mockDialogData: WebVaultGeneratorDialogParams = { type: "password" };
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, WebVaultGeneratorDialogComponent],
providers: [
{
provide: DialogRef,
useValue: dialogRef,
},
{
provide: DIALOG_DATA,
useValue: mockDialogData,
},
{
provide: I18nService,
useValue: mockI18nService,
},
{
provide: PlatformUtilsService,
useValue: mock<PlatformUtilsService>(),
},
{
provide: PasswordGenerationServiceAbstraction,
useValue: mockPasswordGenerationService,
},
{
provide: UsernameGenerationServiceAbstraction,
useValue: mockUsernameGenerationService,
},
{
provide: CipherFormGeneratorComponent,
useValue: {
passwordOptions$: passwordOptionsSubject.asObservable(),
usernameOptions$: usernameOptionsSubject.asObservable(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(WebVaultGeneratorDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("initializes without errors", () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it("closes the dialog with 'canceled' result when close is called", () => {
const closeSpy = jest.spyOn(dialogRef, "close");
(component as any).close();
expect(closeSpy).toHaveBeenCalledWith({
action: WebVaultGeneratorDialogAction.Canceled,
});
});
it("closes the dialog with 'selected' result when selectValue is called", () => {
const closeSpy = jest.spyOn(dialogRef, "close");
const generatedValue = "generated-value";
component.onValueGenerated(generatedValue);
(component as any).selectValue();
expect(closeSpy).toHaveBeenCalledWith({
action: WebVaultGeneratorDialogAction.Selected,
generatedValue: generatedValue,
});
});
it("updates generatedValue when onValueGenerated is called", () => {
const generatedValue = "new-generated-value";
component.onValueGenerated(generatedValue);
expect((component as any).generatedValue).toBe(generatedValue);
});
});

View File

@@ -0,0 +1,87 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
export interface WebVaultGeneratorDialogParams {
type: "password" | "username";
}
export interface WebVaultGeneratorDialogResult {
action: WebVaultGeneratorDialogAction;
generatedValue?: string;
}
export enum WebVaultGeneratorDialogAction {
Selected = "selected",
Canceled = "canceled",
}
@Component({
selector: "web-vault-generator-dialog",
templateUrl: "./web-generator-dialog.component.html",
standalone: true,
imports: [CommonModule, CipherFormGeneratorComponent, ButtonModule, DialogModule],
})
export class WebVaultGeneratorDialogComponent {
protected title = this.i18nService.t(this.isPassword ? "passwordGenerator" : "usernameGenerator");
protected selectButtonText = this.i18nService.t(
this.isPassword ? "useThisPassword" : "useThisUsername",
);
/**
* Whether the dialog is generating a password/passphrase. If false, it is generating a username.
* @protected
*/
protected get isPassword() {
return this.params.type === "password";
}
/**
* The currently generated value.
* @protected
*/
protected generatedValue: string = "";
constructor(
@Inject(DIALOG_DATA) protected params: WebVaultGeneratorDialogParams,
private dialogRef: DialogRef<WebVaultGeneratorDialogResult>,
private i18nService: I18nService,
) {}
/**
* Close the dialog without selecting a value.
*/
protected close = () => {
this.dialogRef.close({ action: WebVaultGeneratorDialogAction.Canceled });
};
/**
* Close the dialog and select the currently generated value.
*/
protected selectValue = () => {
this.dialogRef.close({
action: WebVaultGeneratorDialogAction.Selected,
generatedValue: this.generatedValue,
});
};
onValueGenerated(value: string) {
this.generatedValue = value;
}
/**
* Opens the vault generator dialog.
*/
static open(dialogService: DialogService, config: DialogConfig<WebVaultGeneratorDialogParams>) {
return dialogService.open<WebVaultGeneratorDialogResult, WebVaultGeneratorDialogParams>(
WebVaultGeneratorDialogComponent,
{
...config,
},
);
}
}

View File

@@ -17,9 +17,8 @@ import {
CipherFormModule,
} from "@bitwarden/vault";
import { WebCipherFormGenerationService } from "../../../../../../libs/vault/src/cipher-form/services/web-cipher-form-generation.service";
import { CipherViewComponent } from "../../../../../../libs/vault/src/cipher-view/cipher-view.component";
import { SharedModule } from "../../shared/shared.module";
import { WebCipherFormGenerationService } from "../services/web-cipher-form-generation.service";
import { AttachmentsV2Component } from "./attachments-v2.component";
@@ -48,13 +47,13 @@ export interface AddEditCipherDialogCloseResult {
/**
* Component for viewing a cipher, presented in a dialog.
* @deprecated Use the VaultItemDialogComponent instead.
*/
@Component({
selector: "app-vault-add-edit-v2",
templateUrl: "add-edit-v2.component.html",
standalone: true,
imports: [
CipherViewComponent,
CommonModule,
AsyncActionsModule,
DialogModule,

View File

@@ -1,4 +1,4 @@
<bit-dialog dialogSize="default">
<bit-dialog dialogSize="default" background="alt">
<span bitDialogTitle>
{{ "attachments" | i18n }}
</span>

View File

@@ -1,7 +1,7 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="small">
<span bitDialogTitle>
{{ ((vaultBulkManagementActionEnabled$ | async) ? "addToFolder" : "moveSelected") | i18n }}
{{ "addToFolder" | i18n }}
</span>
<span bitDialogContent>
<p>{{ "moveSelectedItemsDesc" | i18n: cipherIds.length }}</p>

View File

@@ -3,8 +3,6 @@ import { Component, Inject, OnInit } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { firstValueFrom, Observable } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -47,10 +45,6 @@ export class BulkMoveDialogComponent implements OnInit {
});
folders$: Observable<FolderView[]>;
protected vaultBulkManagementActionEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.VaultBulkManagementAction,
);
constructor(
@Inject(DIALOG_DATA) params: BulkMoveDialogParams,
private dialogRef: DialogRef<BulkMoveDialogResult>,
@@ -59,7 +53,6 @@ export class BulkMoveDialogComponent implements OnInit {
private i18nService: I18nService,
private folderService: FolderService,
private formBuilder: FormBuilder,
private configService: ConfigService,
) {
this.cipherIds = params.cipherIds ?? [];
}

View File

@@ -54,9 +54,8 @@
[showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="false"
[showAdminActions]="false"
[showBulkAddToCollections]="vaultBulkManagementActionEnabled$ | async"
[showBulkAddToCollections]="true"
(onEvent)="onVaultItemsEvent($event)"
[vaultBulkManagementActionEnabled]="vaultBulkManagementActionEnabled$ | async"
>
</app-vault-items>
<div

View File

@@ -1,3 +1,4 @@
import { DialogRef } from "@angular/cdk/dialog";
import {
ChangeDetectorRef,
Component,
@@ -63,6 +64,7 @@ import { CollectionView } from "@bitwarden/common/vault/models/view/collection.v
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { DialogService, Icons, ToastService } from "@bitwarden/components";
import {
CipherFormConfig,
CollectionAssignmentResult,
DefaultCipherFormConfigService,
PasswordRepromptService,
@@ -75,16 +77,16 @@ import {
CollectionDialogTabType,
openCollectionDialog,
} from "../components/collection-dialog";
import {
VaultItemDialogComponent,
VaultItemDialogMode,
VaultItemDialogResult,
} from "../components/vault-item-dialog/vault-item-dialog.component";
import { VaultItem } from "../components/vault-items/vault-item";
import { VaultItemEvent } from "../components/vault-items/vault-item-event";
import { VaultItemsModule } from "../components/vault-items/vault-items.module";
import { getNestedCollectionTree } from "../utils/collection-utils";
import {
AddEditCipherDialogCloseResult,
AddEditCipherDialogResult,
openAddEditCipherDialog,
} from "./add-edit-v2.component";
import { AddEditComponent } from "./add-edit.component";
import {
AttachmentDialogCloseResult,
@@ -100,16 +102,7 @@ import {
BulkMoveDialogResult,
openBulkMoveDialog,
} from "./bulk-action-dialogs/bulk-move-dialog/bulk-move-dialog.component";
import {
BulkShareDialogResult,
openBulkShareDialog,
} from "./bulk-action-dialogs/bulk-share-dialog/bulk-share-dialog.component";
import {
CollectionsDialogResult,
openIndividualVaultCollectionsDialog,
} from "./collections.component";
import { FolderAddEditDialogResult, openFolderAddEditDialog } from "./folder-add-edit.component";
import { ShareComponent } from "./share.component";
import { VaultBannersComponent } from "./vault-banners/vault-banners.component";
import { VaultFilterComponent } from "./vault-filter/components/vault-filter.component";
import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service";
@@ -125,11 +118,6 @@ import { FolderFilter, OrganizationFilter } from "./vault-filter/shared/models/v
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultHeaderComponent } from "./vault-header/vault-header.component";
import { VaultOnboardingComponent } from "./vault-onboarding/vault-onboarding.component";
import {
openViewCipherDialog,
ViewCipherDialogCloseResult,
ViewCipherDialogResult,
} from "./view.component";
const BroadcasterSubscriptionId = "VaultComponent";
const SearchTextDebounceInterval = 200;
@@ -183,14 +171,13 @@ export class VaultComponent implements OnInit, OnDestroy {
protected selectedCollection: TreeNode<CollectionView> | undefined;
protected canCreateCollections = false;
protected currentSearchText$: Observable<string>;
protected vaultBulkManagementActionEnabled$ = this.configService.getFeatureFlag$(
FeatureFlag.VaultBulkManagementAction,
);
private searchText$ = new Subject<string>();
private refresh$ = new BehaviorSubject<void>(null);
private destroy$ = new Subject<void>();
private extensionRefreshEnabled: boolean;
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
constructor(
private syncService: SyncService,
private route: ActivatedRoute,
@@ -364,12 +351,20 @@ export class VaultComponent implements OnInit, OnDestroy {
firstSetup$
.pipe(
switchMap(() => this.route.queryParams),
// Only process the queryParams if the dialog is not open (only when extension refresh is enabled)
filter(() => this.vaultItemDialogRef == undefined || !this.extensionRefreshEnabled),
switchMap(async (params) => {
const cipherId = getCipherIdFromParams(params);
if (cipherId) {
if (await this.cipherService.get(cipherId)) {
if (params.action === "view") {
let action = params.action;
// Default to "view" if extension refresh is enabled
if (action == null && this.extensionRefreshEnabled) {
action = "view";
}
if (action === "view") {
await this.viewCipherById(cipherId);
} else {
await this.editCipherId(cipherId);
@@ -458,9 +453,6 @@ export class VaultComponent implements OnInit, OnDestroy {
case "viewAttachments":
await this.editCipherAttachments(event.item);
break;
case "viewCipherCollections":
await this.editCipherCollections(event.item);
break;
case "clone":
await this.cloneCipher(event.item);
break;
@@ -477,13 +469,6 @@ export class VaultComponent implements OnInit, OnDestroy {
case "moveToFolder":
await this.bulkMove(event.items);
break;
case "moveToOrganization":
if (event.items.length === 1) {
await this.shareCipher(event.items[0]);
} else {
await this.bulkShare(event.items);
}
break;
case "copyField":
await this.copy(event.item, event.field);
break;
@@ -548,7 +533,7 @@ export class VaultComponent implements OnInit, OnDestroy {
*/
async editCipherAttachments(cipher: CipherView) {
if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) {
this.go({ cipherId: null, itemId: null });
await this.go({ cipherId: null, itemId: null });
return;
}
@@ -566,9 +551,6 @@ export class VaultComponent implements OnInit, OnDestroy {
}
const canEditAttachments = await this.canEditAttachments(cipher);
const vaultBulkManagementActionEnabled = await firstValueFrom(
this.vaultBulkManagementActionEnabled$,
);
let madeAttachmentChanges = false;
@@ -594,7 +576,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.attachmentsModalRef,
(comp) => {
comp.cipherId = cipher.id;
comp.viewOnly = !canEditAttachments && vaultBulkManagementActionEnabled;
comp.viewOnly = !canEditAttachments;
comp.onUploadedAttachment
.pipe(takeUntil(this.destroy$))
.subscribe(() => (madeAttachmentChanges = true));
@@ -615,39 +597,27 @@ export class VaultComponent implements OnInit, OnDestroy {
});
}
async shareCipher(cipher: CipherView) {
if (cipher.organizationId != null) {
// You cannot move ciphers between organizations
this.showMissingPermissionsError();
return;
}
if (cipher?.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) {
this.go({ cipherId: null, itemId: null });
return;
}
const [modal] = await this.modalService.openViewRef(
ShareComponent,
this.shareModalRef,
(comp) => {
comp.cipherId = cipher.id;
comp.onSharedCipher.pipe(takeUntil(this.destroy$)).subscribe(() => {
modal.close();
this.refresh();
});
},
);
}
async editCipherCollections(cipher: CipherView) {
const dialog = openIndividualVaultCollectionsDialog(this.dialogService, {
data: { cipherId: cipher.id },
/**
* Open the combined view / edit dialog for a cipher.
* @param mode - Starting mode of the dialog.
* @param formConfig - Configuration for the form when editing/adding a cipher.
*/
async openVaultItemDialog(mode: VaultItemDialogMode, formConfig: CipherFormConfig) {
this.vaultItemDialogRef = VaultItemDialogComponent.open(this.dialogService, {
mode,
formConfig,
});
const result = await lastValueFrom(dialog.closed);
if (result === CollectionsDialogResult.Saved) {
const result = await lastValueFrom(this.vaultItemDialogRef.closed);
this.vaultItemDialogRef = undefined;
// If the dialog was closed by deleting the cipher, refresh the vault.
if (result === VaultItemDialogResult.Deleted || result === VaultItemDialogResult.Saved) {
this.refresh();
}
// Clear the query params when the dialog closes
await this.go({ cipherId: null, itemId: null, action: null });
}
async addCipher(cipherType?: CipherType) {
@@ -703,23 +673,7 @@ export class VaultComponent implements OnInit, OnDestroy {
folderId: this.activeFilter.folderId,
};
// Open the dialog.
const dialogRef = openAddEditCipherDialog(this.dialogService, {
data: cipherFormConfig,
});
// Wait for the dialog to close.
const result: AddEditCipherDialogCloseResult = await lastValueFrom(dialogRef.closed);
// Refresh the vault to show the new cipher.
if (result?.action === AddEditCipherDialogResult.Added) {
this.refresh();
this.go({ itemId: result.id, action: "view" });
return;
}
// If the dialog was closed by any other action navigate back to the vault.
this.go({ cipherId: null, itemId: null, action: null });
await this.openVaultItemDialog("form", cipherFormConfig);
}
async editCipher(cipher: CipherView, cloneMode?: boolean) {
@@ -735,7 +689,7 @@ export class VaultComponent implements OnInit, OnDestroy {
!(await this.passwordRepromptService.showPasswordPrompt())
) {
// didn't pass password prompt, so don't open add / edit modal
this.go({ cipherId: null, itemId: null, action: null });
await this.go({ cipherId: null, itemId: null, action: null });
return;
}
@@ -767,14 +721,14 @@ export class VaultComponent implements OnInit, OnDestroy {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
modal.onClosedPromise().then(() => {
this.go({ cipherId: null, itemId: null, action: null });
void this.go({ cipherId: null, itemId: null, action: null });
});
return childComponent;
}
/**
* Edit a cipher using the new AddEditCipherDialogV2 component.
* Edit a cipher using the new VaultItemDialog.
*
* @param cipher
* @param cloneMode
@@ -786,31 +740,7 @@ export class VaultComponent implements OnInit, OnDestroy {
cipher.type,
);
const dialogRef = openAddEditCipherDialog(this.dialogService, {
data: cipherFormConfig,
});
const result: AddEditCipherDialogCloseResult = await firstValueFrom(dialogRef.closed);
/**
* Refresh the vault if the dialog was closed by adding, editing, or deleting a cipher.
*/
if (result?.action === AddEditCipherDialogResult.Edited) {
this.refresh();
}
/**
* View the cipher if the dialog was closed by editing the cipher.
*/
if (result?.action === AddEditCipherDialogResult.Edited) {
this.go({ itemId: cipher.id, action: "view" });
return;
}
/**
* Navigate to the vault if the dialog was closed by any other action.
*/
this.go({ cipherId: null, itemId: null, action: null });
await this.openVaultItemDialog("form", cipherFormConfig);
}
/**
@@ -837,39 +767,17 @@ export class VaultComponent implements OnInit, OnDestroy {
!(await this.passwordRepromptService.showPasswordPrompt())
) {
// Didn't pass password prompt, so don't open add / edit modal.
this.go({ cipherId: null, itemId: null });
await this.go({ cipherId: null, itemId: null, action: null });
return;
}
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
// Decrypt the cipher.
const cipherView = await cipher.decrypt(
await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId),
const cipherFormConfig = await this.cipherFormConfigService.buildConfig(
cipher.edit ? "edit" : "partial-edit",
cipher.id as CipherId,
cipher.type,
);
// Open the dialog.
const dialogRef = openViewCipherDialog(this.dialogService, {
data: { cipher: cipherView },
});
// Wait for the dialog to close.
const result: ViewCipherDialogCloseResult = await lastValueFrom(dialogRef.closed);
// If the dialog was closed by clicking the edit button, navigate to open the edit dialog.
if (result?.action === ViewCipherDialogResult.Edited) {
this.go({ itemId: cipherView.id, action: "edit" });
return;
}
// If the dialog was closed by deleting the cipher, refresh the vault.
if (result?.action === ViewCipherDialogResult.Deleted) {
this.refresh();
}
// Clear the query params when the view dialog closes
this.go({ cipherId: null, itemId: null, action: null });
await this.openVaultItemDialog("view", cipherFormConfig);
}
async addCollection() {
@@ -1018,7 +926,10 @@ export class VaultComponent implements OnInit, OnDestroy {
}
const component = await this.editCipher(cipher, true);
component.cloneMode = true;
if (component != null) {
component.cloneMode = true;
}
}
async restore(c: CipherView): Promise<boolean> {
@@ -1255,34 +1166,6 @@ export class VaultComponent implements OnInit, OnDestroy {
}
}
async bulkShare(ciphers: CipherView[]) {
if (!(await this.repromptCipher(ciphers))) {
return;
}
if (ciphers.some((c) => c.organizationId != null)) {
// You cannot move ciphers between organizations
this.showMissingPermissionsError();
return;
}
if (ciphers.length === 0) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("nothingSelected"),
});
return;
}
const dialog = openBulkShareDialog(this.dialogService, { data: { ciphers } });
const result = await lastValueFrom(dialog.closed);
if (result === BulkShareDialogResult.Shared) {
this.refresh();
}
}
protected deleteCipherWithServer(id: string, permanent: boolean) {
return permanent
? this.cipherService.deleteWithServer(id)
@@ -1308,7 +1191,7 @@ export class VaultComponent implements OnInit, OnDestroy {
return organization.canEditAllCiphers;
}
private go(queryParams: any = null) {
private async go(queryParams: any = null) {
if (queryParams == null) {
queryParams = {
favorites: this.activeFilter.isFavorites || null,
@@ -1319,7 +1202,7 @@ export class VaultComponent implements OnInit, OnDestroy {
};
}
void this.router.navigate([], {
await this.router.navigate([], {
relativeTo: this.route,
queryParams: queryParams,
queryParamsHandling: "merge",

View File

@@ -1,6 +1,6 @@
import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Component, Inject, OnInit, EventEmitter } from "@angular/core";
import { Component, EventEmitter, Inject, OnInit } from "@angular/core";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -52,6 +52,7 @@ export interface ViewCipherDialogCloseResult {
/**
* Component for viewing a cipher, presented in a dialog.
* @deprecated Use the VaultItemDialogComponent instead.
*/
@Component({
selector: "app-vault-view",

View File

@@ -598,9 +598,6 @@ export class VaultComponent implements OnInit, OnDestroy {
case "viewAttachments":
await this.editCipherAttachments(event.item);
break;
case "viewCipherCollections":
await this.editCipherCollections(event.item);
break;
case "clone":
await this.cloneCipher(event.item);
break;

View File

@@ -0,0 +1,88 @@
import { DialogRef } from "@angular/cdk/dialog";
import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { DialogService } from "@bitwarden/components";
import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component";
import { WebCipherFormGenerationService } from "./web-cipher-form-generation.service";
describe("WebCipherFormGenerationService", () => {
let service: WebCipherFormGenerationService;
let dialogService: jest.Mocked<DialogService>;
let closed = of({});
const close = jest.fn();
const dialogRef = {
close,
get closed() {
return closed;
},
} as unknown as DialogRef<unknown, unknown>;
beforeEach(() => {
dialogService = mock<DialogService>();
TestBed.configureTestingModule({
providers: [
WebCipherFormGenerationService,
{ provide: DialogService, useValue: dialogService },
],
});
service = TestBed.inject(WebCipherFormGenerationService);
});
it("creates without error", () => {
expect(service).toBeTruthy();
});
describe("generatePassword", () => {
it("opens the password generator dialog and returns the generated value", async () => {
const generatedValue = "generated-password";
closed = of({ action: "generated", generatedValue });
dialogService.open.mockReturnValue(dialogRef);
const result = await service.generatePassword();
expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, {
data: { type: "password" },
});
expect(result).toBe(generatedValue);
});
it("returns null if the dialog is canceled", async () => {
closed = of({ action: "canceled" });
dialogService.open.mockReturnValue(dialogRef);
const result = await service.generatePassword();
expect(result).toBeNull();
});
});
describe("generateUsername", () => {
it("opens the username generator dialog and returns the generated value", async () => {
const generatedValue = "generated-username";
closed = of({ action: "generated", generatedValue });
dialogService.open.mockReturnValue(dialogRef);
const result = await service.generateUsername();
expect(dialogService.open).toHaveBeenCalledWith(WebVaultGeneratorDialogComponent, {
data: { type: "username" },
});
expect(result).toBe(generatedValue);
});
it("returns null if the dialog is canceled", async () => {
closed = of({ action: "canceled" });
dialogService.open.mockReturnValue(dialogRef);
const result = await service.generateUsername();
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,40 @@
import { inject, Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { DialogService } from "@bitwarden/components";
import { CipherFormGenerationService } from "@bitwarden/vault";
import { WebVaultGeneratorDialogComponent } from "../components/web-generator-dialog/web-generator-dialog.component";
@Injectable()
export class WebCipherFormGenerationService implements CipherFormGenerationService {
private dialogService = inject(DialogService);
async generatePassword(): Promise<string> {
const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, {
data: { type: "password" },
});
const result = await firstValueFrom(dialogRef.closed);
if (result == null || result.action === "canceled") {
return null;
}
return result.generatedValue;
}
async generateUsername(): Promise<string> {
const dialogRef = WebVaultGeneratorDialogComponent.open(this.dialogService, {
data: { type: "username" },
});
const result = await firstValueFrom(dialogRef.closed);
if (result == null || result.action === "canceled") {
return null;
}
return result.generatedValue;
}
}

View File

@@ -1,15 +1,12 @@
import { DialogRef } from "@angular/cdk/dialog";
import { TestBed } from "@angular/core/testing";
import { Router } from "@angular/router";
import { of, lastValueFrom } from "rxjs";
import { lastValueFrom, of } from "rxjs";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components";
import {
ViewCipherDialogCloseResult,
ViewCipherDialogResult,
} from "../individual-vault/view.component";
import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component";
import { WebVaultPremiumUpgradePromptService } from "./web-premium-upgrade-prompt.service";
@@ -17,7 +14,7 @@ describe("WebVaultPremiumUpgradePromptService", () => {
let service: WebVaultPremiumUpgradePromptService;
let dialogServiceMock: jest.Mocked<DialogService>;
let routerMock: jest.Mocked<Router>;
let dialogRefMock: jest.Mocked<DialogRef<ViewCipherDialogCloseResult>>;
let dialogRefMock: jest.Mocked<DialogRef<VaultItemDialogResult>>;
beforeEach(() => {
dialogServiceMock = {
@@ -30,7 +27,7 @@ describe("WebVaultPremiumUpgradePromptService", () => {
dialogRefMock = {
close: jest.fn(),
} as unknown as jest.Mocked<DialogRef<ViewCipherDialogCloseResult>>;
} as unknown as jest.Mocked<DialogRef<VaultItemDialogResult>>;
TestBed.configureTestingModule({
providers: [
@@ -62,9 +59,7 @@ describe("WebVaultPremiumUpgradePromptService", () => {
"billing",
"subscription",
]);
expect(dialogRefMock.close).toHaveBeenCalledWith({
action: ViewCipherDialogResult.PremiumUpgrade,
});
expect(dialogRefMock.close).toHaveBeenCalledWith(VaultItemDialogResult.PremiumUpgrade);
});
it("prompts for premium upgrade and navigates to premium subscription if organizationId is not provided", async () => {
@@ -79,9 +74,7 @@ describe("WebVaultPremiumUpgradePromptService", () => {
type: "success",
});
expect(routerMock.navigate).toHaveBeenCalledWith(["settings/subscription/premium"]);
expect(dialogRefMock.close).toHaveBeenCalledWith({
action: ViewCipherDialogResult.PremiumUpgrade,
});
expect(dialogRefMock.close).toHaveBeenCalledWith(VaultItemDialogResult.PremiumUpgrade);
});
it("does not navigate or close dialog if upgrade is no action is taken", async () => {

View File

@@ -6,10 +6,7 @@ import { OrganizationId } from "@bitwarden/common/types/guid";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { DialogService } from "@bitwarden/components";
import {
ViewCipherDialogCloseResult,
ViewCipherDialogResult,
} from "../individual-vault/view.component";
import { VaultItemDialogResult } from "../components/vault-item-dialog/vault-item-dialog.component";
/**
* This service is used to prompt the user to upgrade to premium.
@@ -19,7 +16,7 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
constructor(
private dialogService: DialogService,
private router: Router,
private dialog: DialogRef<ViewCipherDialogCloseResult>,
private dialog: DialogRef<VaultItemDialogResult>,
) {}
/**
@@ -51,7 +48,7 @@ export class WebVaultPremiumUpgradePromptService implements PremiumUpgradePrompt
}
if (upgradeConfirmed) {
this.dialog.close({ action: ViewCipherDialogResult.PremiumUpgrade });
this.dialog.close(VaultItemDialogResult.PremiumUpgrade);
}
}
}

View File

@@ -54,8 +54,10 @@ function redirectToDuoFrameless(redirectUrl: string) {
if (
validateUrl.protocol !== "https:" ||
!validateUrl.hostname.endsWith("duosecurity.com") ||
!validateUrl.hostname.endsWith("duofederal.com")
!(
validateUrl.hostname.endsWith("duosecurity.com") ||
validateUrl.hostname.endsWith("duofederal.com")
)
) {
throw new Error("Invalid redirect URL");
}

View File

@@ -9277,5 +9277,14 @@
},
"editAccess": {
"message": "Edit access"
},
"addAttachment": {
"message": "Add attachment"
},
"maxFileSizeSansPunctuation": {
"message": "Maximum file size is 500 MB"
},
"permanentlyDeleteAttachmentConfirmation": {
"message": "Are you sure you want to permanently delete this attachment?"
}
}