1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 05:43:41 +00:00

[PM-18520] - Update desktop cipher forms to use the same UI as web app and extension - (#13992)

* WIP - cipher form refactor

* cipher clone

* cipher clone

* finalize item view and form changes

* fix tests

* hide changes behind feature flag

* set flag to false

* create vault items v2. add button selector

* revert change to flag and vault items

* add attachments

* revert change to tsconfig

* move module

* fix modules

* cleanup

* fix import

* fix import

* fix import

* remove showForm

* update feature flag

* wip - cleanup

* fix up services

* cleanup

* fix type errors

* fix lint errors

* add dialog component

* revert changes to menu

* revert changes to menu

* fix vault-items-v2

* set feature flag to FALSE

* add missing i18n keys. fix collection state

* remove generator. update modules. bug fix

* fix restricted imports

* mark method as deprecated. add uri arg back

* fix shared.module

* fix shared.module

* fix shared.module

* add uri

* check and prompt for premium when opening attachments dialog

* move VaultItemDialogResult back

* fix import in spec file

* update copy functions

* fix MP reprompt issue
This commit is contained in:
Jordan Aasen
2025-04-23 11:13:44 -07:00
committed by GitHub
parent ef80c23707
commit b589951c90
36 changed files with 1569 additions and 89 deletions

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { inject } from "@angular/core";
import { Router } from "@angular/router";

View File

@@ -14,6 +14,7 @@ import {
tdeDecryptionRequiredGuard,
unauthGuardFn,
} from "@bitwarden/angular/auth/guards";
import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route";
import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards";
import {
AnonLayoutWrapperComponent,
@@ -41,6 +42,7 @@ import {
NewDeviceVerificationComponent,
DeviceVerificationIcon,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { LockComponent } from "@bitwarden/key-management-ui";
import {
NewDeviceVerificationNoticePageOneComponent,
@@ -53,6 +55,7 @@ import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
import { SetPasswordComponent } from "../auth/set-password.component";
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import { VaultV2Component } from "../vault/app/vault/vault-v2.component";
import { VaultComponent } from "../vault/app/vault/vault.component";
import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component";
@@ -132,11 +135,15 @@ const routes: Routes = [
},
],
},
{
...featureFlaggedRoute({
defaultComponent: VaultComponent,
flaggedComponent: VaultV2Component,
featureFlag: FeatureFlag.PM18520_UpdateDesktopCipherForm,
routeOptions: {
path: "vault",
component: VaultComponent,
canActivate: [authGuard, NewDeviceVerificationNoticeGuard],
},
}),
{ path: "accessibility-cookie", component: AccessibilityCookieComponent },
{ path: "set-password", component: SetPasswordComponent },
{
@@ -359,7 +366,7 @@ const routes: Routes = [
imports: [
RouterModule.forRoot(routes, {
useHash: true,
/*enableTracing: true,*/
// enableTracing: true,
}),
],
exports: [RouterModule],

View File

@@ -9,7 +9,6 @@ import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe";
import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe";
import { CalloutModule, DialogModule } from "@bitwarden/components";
import { DecryptionFailureDialogComponent } from "@bitwarden/vault";
import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component";
import { DeleteAccountComponent } from "../auth/delete-account.component";
@@ -28,6 +27,7 @@ import { PasswordHistoryComponent } from "../vault/app/vault/password-history.co
import { ShareComponent } from "../vault/app/vault/share.component";
import { VaultFilterModule } from "../vault/app/vault/vault-filter/vault-filter.module";
import { VaultItemsComponent } from "../vault/app/vault/vault-items.component";
import { VaultV2Component } from "../vault/app/vault/vault-v2.component";
import { VaultComponent } from "../vault/app/vault/vault.component";
import { ViewCustomFieldsComponent } from "../vault/app/vault/view-custom-fields.component";
import { ViewComponent } from "../vault/app/vault/view.component";
@@ -55,8 +55,8 @@ import { SharedModule } from "./shared/shared.module";
CalloutModule,
DeleteAccountComponent,
UserVerificationComponent,
DecryptionFailureDialogComponent,
NavComponent,
VaultV2Component,
],
declarations: [
AccessibilityCookieComponent,
@@ -65,7 +65,6 @@ import { SharedModule } from "./shared/shared.module";
AddEditCustomFieldsComponent,
AppComponent,
AttachmentsComponent,
VaultItemsComponent,
CollectionsComponent,
ColorPasswordPipe,
ColorPasswordCountPipe,
@@ -80,9 +79,10 @@ import { SharedModule } from "./shared/shared.module";
ShareComponent,
UpdateTempPasswordComponent,
VaultComponent,
VaultItemsComponent,
VaultTimeoutInputComponent,
ViewComponent,
ViewCustomFieldsComponent,
ViewComponent,
],
providers: [SshAgentService],
bootstrap: [AppComponent],

View File

@@ -393,6 +393,64 @@
"authenticatorKeyTotp": {
"message": "Authenticator key (TOTP)"
},
"authenticatorKey": {
"message": "Authenticator key"
},
"autofillOptions": {
"message": "Autofill options"
},
"websiteUri": {
"message": "Website (URI)"
},
"websiteUriCount": {
"message": "Website (URI) $COUNT$",
"description": "Label for an input field that contains a website URI. The input field is part of a list of fields, and the count indicates the position of the field in the list.",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"websiteAdded": {
"message": "Website added"
},
"addWebsite": {
"message": "Add website"
},
"deleteWebsite": {
"message": "Delete website"
},
"owner": {
"message": "Owner"
},
"addField": {
"message": "Add field"
},
"fieldType": {
"message": "Field type"
},
"fieldLabel": {
"message": "Field label"
},
"add": {
"message": "Add"
},
"textHelpText": {
"message": "Use text fields for data like security questions"
},
"hiddenHelpText": {
"message": "Use hidden fields for sensitive data like a password"
},
"checkBoxHelpText": {
"message": "Use checkboxes if you'd like to autofill a form's checkbox, like a remember email"
},
"linkedHelpText": {
"message": "Use a linked field when you are experiencing autofill issues for a specific website."
},
"linkedLabelHelpText": {
"message": "Enter the the field's html id, name, aria-label, or placeholder."
},
"folder": {
"message": "Folder"
},
@@ -418,6 +476,9 @@
"message": "Linked",
"description": "This describes a field that is 'linked' (related) to another field."
},
"cfTypeCheckbox": {
"message": "Checkbox"
},
"linkedValue": {
"message": "Linked value",
"description": "This describes a value that is 'linked' (related) to another value."
@@ -1915,6 +1976,43 @@
}
}
},
"cardDetails": {
"message": "Card details"
},
"cardBrandDetails": {
"message": "$BRAND$ details",
"placeholders": {
"brand": {
"content": "$1",
"example": "Visa"
}
}
},
"learnMoreAboutAuthenticators": {
"message": "Learn more about authenticators"
},
"copyTOTP": {
"message": "Copy Authenticator key (TOTP)"
},
"totpHelperTitle": {
"message": "Make 2-step verification seamless"
},
"totpHelper": {
"message": "Bitwarden can store and fill 2-step verification codes. Copy and paste the key into this field."
},
"totpHelperWithCapture": {
"message": "Bitwarden can store and fill 2-step verification codes. Select the camera icon to take a screenshot of this website's authenticator QR code, or copy and paste the key into this field."
},
"premium": {
"message": "Premium",
"description": "Premium membership"
},
"cardExpiredTitle": {
"message": "Expired card"
},
"cardExpiredMessage": {
"message": "If you've renewed it, update the card's information"
},
"verificationRequired": {
"message": "Verification required",
"description": "Default title for the user verification dialog."
@@ -2084,6 +2182,15 @@
"personalOwnershipPolicyInEffectImports": {
"message": "An organization policy has blocked importing items into your individual vault."
},
"personalDetails": {
"message": "Personal details"
},
"identification": {
"message": "Identification"
},
"contactInfo": {
"message": "Contact information"
},
"allSends": {
"message": "All Sends",
"description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
@@ -2518,6 +2625,9 @@
"generateEmail": {
"message": "Generate email"
},
"usernameGenerator": {
"message": "Username generator"
},
"spinboxBoundariesHint": {
"message": "Value must be between $MIN$ and $MAX$.",
"description": "Explains spin box minimum and maximum values to the user",
@@ -3439,6 +3549,17 @@
"ssoError": {
"message": "No free ports could be found for the sso login."
},
"securePasswordGenerated": {
"message": "Secure password generated! Don't forget to also update your password on the website."
},
"useGeneratorHelpTextPartOne": {
"message": "Use the generator",
"description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'"
},
"useGeneratorHelpTextPartTwo": {
"message": "to create a strong unique password",
"description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'"
},
"biometricsStatusHelptextUnlockNeeded": {
"message": "Biometric unlock is unavailable because PIN or password unlock is required first."
},
@@ -3517,6 +3638,27 @@
"setupTwoStepLogin": {
"message": "Set up two-step login"
},
"itemDetails": {
"message": "Item details"
},
"itemName": {
"message": "Item name"
},
"loginCredentials": {
"message": "Login credentials"
},
"additionalOptions": {
"message": "Additional options"
},
"itemHistory": {
"message": "Item history"
},
"lastEdited": {
"message": "Last edited"
},
"upload": {
"message": "Upload"
},
"newDeviceVerificationNoticeContentPage1": {
"message": "Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025."
},

View File

@@ -147,3 +147,8 @@ div:not(.modal)::-webkit-scrollbar-thumb,
.mx-auto {
margin-left: auto !important;
}
.vault-v2 button:not([bitbutton]):not([biticonbutton]) i.bwi,
a i.bwi {
margin-right: 0.25rem;
}

View File

@@ -162,3 +162,7 @@ app-root {
}
}
}
.vault-v2 > .details {
flex-direction: column-reverse;
}

View File

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

View File

@@ -0,0 +1,30 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { DesktopPremiumUpgradePromptService } from "./desktop-premium-upgrade-prompt.service";
describe("DesktopPremiumUpgradePromptService", () => {
let service: DesktopPremiumUpgradePromptService;
let messager: MockProxy<MessagingService>;
beforeEach(async () => {
messager = mock<MessagingService>();
await TestBed.configureTestingModule({
providers: [
DesktopPremiumUpgradePromptService,
{ provide: MessagingService, useValue: messager },
],
}).compileComponents();
service = TestBed.inject(DesktopPremiumUpgradePromptService);
});
describe("promptForPremium", () => {
it("navigates to the premium update screen", async () => {
await service.promptForPremium();
expect(messager.send).toHaveBeenCalledWith("openPremium");
});
});
});

View File

@@ -0,0 +1,15 @@
import { inject } from "@angular/core";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
/**
* This class handles the premium upgrade process for the desktop.
*/
export class DesktopPremiumUpgradePromptService implements PremiumUpgradePromptService {
private messagingService = inject(MessagingService);
async promptForPremium() {
this.messagingService.send("openPremium");
}
}

View File

@@ -3,6 +3,7 @@
<ng-container bitDialogContent>
<vault-cipher-form-generator
[type]="data.type"
[uri]="data.uri"
(valueGenerated)="onCredentialGenerated($event)"
(algorithmSelected)="onAlgorithmSelected($event)"
/>
@@ -27,7 +28,6 @@
(click)="applyCredentials()"
appA11yTitle="{{ buttonLabel }}"
bitButton
bitDialogClose
[disabled]="!(buttonLabel && credentialValue)"
>
{{ buttonLabel }}

View File

@@ -10,6 +10,7 @@ import {
DialogService,
ItemModule,
LinkModule,
DialogRef,
} from "@bitwarden/components";
import {
CredentialGeneratorHistoryDialogComponent,
@@ -19,10 +20,22 @@ import { AlgorithmInfo } from "@bitwarden/generator-core";
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
type CredentialGeneratorParams = {
onCredentialGenerated: (value?: string) => void;
/** @deprecated Prefer use of dialogRef.closed to retreive the generated value */
onCredentialGenerated?: (value?: string) => void;
type: "password" | "username";
uri?: string;
};
export interface CredentialGeneratorDialogResult {
action: CredentialGeneratorDialogAction;
generatedValue?: string;
}
export enum CredentialGeneratorDialogAction {
Selected = "selected",
Canceled = "canceled",
}
@Component({
standalone: true,
selector: "credential-generator-dialog",
@@ -45,6 +58,7 @@ export class CredentialGeneratorDialogComponent {
constructor(
@Inject(DIALOG_DATA) protected data: CredentialGeneratorParams,
private dialogService: DialogService,
private dialogRef: DialogRef<CredentialGeneratorDialogResult>,
private i18nService: I18nService,
) {}
@@ -59,11 +73,15 @@ export class CredentialGeneratorDialogComponent {
};
applyCredentials = () => {
this.data.onCredentialGenerated(this.credentialValue);
this.data.onCredentialGenerated?.(this.credentialValue);
this.dialogRef.close({
action: CredentialGeneratorDialogAction.Selected,
generatedValue: this.credentialValue,
});
};
clearCredentials = () => {
this.data.onCredentialGenerated();
this.data.onCredentialGenerated?.();
};
onCredentialGenerated = (value: string) => {
@@ -75,9 +93,12 @@ export class CredentialGeneratorDialogComponent {
this.dialogService.open(CredentialGeneratorHistoryDialogComponent);
};
static open = (dialogService: DialogService, data: CredentialGeneratorParams) => {
dialogService.open(CredentialGeneratorDialogComponent, {
static open(dialogService: DialogService, data: CredentialGeneratorParams) {
return dialogService.open<CredentialGeneratorDialogResult, CredentialGeneratorParams>(
CredentialGeneratorDialogComponent,
{
data,
});
};
},
);
}
}

View File

@@ -0,0 +1,64 @@
<div class="footer">
<ng-container *ngIf="!cipher.decryptionFailure">
<button
#submitBtn
bitButton
form="cipherForm"
type="submit"
*ngIf="action !== 'view'"
class="primary"
appA11yTitle="{{ 'save' | i18n }}"
>
<i class="bwi bwi-save-changes bwi-lg bwi-fw" [hidden]="isSubmitting" aria-hidden="true"></i>
<i
class="bwi bwi-spinner bwi-spin bwi-lg bwi-fw"
[hidden]="!isSubmitting"
aria-hidden="true"
></i>
</button>
<button
type="button"
class="primary"
(click)="edit()"
appA11yTitle="{{ 'edit' | i18n }}"
*ngIf="!cipher.isDeleted && action === 'view'"
>
<i class="bwi bwi-pencil bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
<button
*ngIf="action === 'edit' || action === 'clone' || action === 'add'"
type="button"
(click)="cancel()"
>
{{ "cancel" | i18n }}
</button>
<button
type="button"
class="primary"
(click)="restore()"
appA11yTitle="{{ 'restore' | i18n }}"
*ngIf="cipher.isDeleted"
>
<i class="bwi bwi-undo bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
<button
type="button"
class="primary"
*ngIf="cipher.id && !cipher?.organizationId && !cipher.isDeleted && action !== 'clone'"
(click)="clone()"
appA11yTitle="{{ 'clone' | i18n }}"
>
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
</button>
</ng-container>
<div class="right" *ngIf="((canDeleteCipher$ | async) && action === 'edit') || action === 'view'">
<button
type="button"
(click)="delete()"
class="danger"
appA11yTitle="{{ (cipher.isDeleted ? 'permanentlyDelete' : 'delete') | i18n }}"
>
<i class="bwi bwi-trash bwi-lg bwi-fw" aria-hidden="true"></i>
</button>
</div>
</div>

View File

@@ -0,0 +1,159 @@
import { CommonModule } from "@angular/common";
import { Input, Output, EventEmitter, Component, OnInit, ViewChild } from "@angular/core";
import { Observable, firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CollectionId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherRepromptType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { ButtonComponent, ButtonModule, DialogService, ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
@Component({
selector: "app-vault-item-footer",
templateUrl: "item-footer.component.html",
standalone: true,
imports: [ButtonModule, CommonModule, JslibModule],
})
export class ItemFooterComponent implements OnInit {
@Input({ required: true }) cipher: CipherView = new CipherView();
@Input() collectionId: string | null = null;
@Input({ required: true }) action: string = "view";
@Input() isSubmitting: boolean = false;
@Output() onEdit = new EventEmitter<CipherView>();
@Output() onClone = new EventEmitter<CipherView>();
@Output() onDelete = new EventEmitter<CipherView>();
@Output() onRestore = new EventEmitter<CipherView>();
@Output() onCancel = new EventEmitter<CipherView>();
@ViewChild("submitBtn", { static: false }) submitBtn: ButtonComponent | null = null;
canDeleteCipher$: Observable<boolean> = new Observable();
activeUserId: UserId | null = null;
private passwordReprompted = false;
constructor(
protected cipherService: CipherService,
protected dialogService: DialogService,
protected passwordRepromptService: PasswordRepromptService,
protected cipherAuthorizationService: CipherAuthorizationService,
protected accountService: AccountService,
protected toastService: ToastService,
protected i18nService: I18nService,
protected logService: LogService,
) {}
async ngOnInit() {
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
this.collectionId as CollectionId,
]);
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
}
async clone() {
if (this.cipher.login?.hasFido2Credentials) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "passkeyNotCopied" },
content: { key: "passkeyNotCopiedAlert" },
type: "info",
});
if (!confirmed) {
return false;
}
}
if (await this.promptPassword()) {
this.onClone.emit(this.cipher);
return true;
}
return false;
}
protected edit() {
this.onEdit.emit(this.cipher);
}
cancel() {
this.onCancel.emit(this.cipher);
}
async delete(): Promise<boolean> {
if (!(await this.promptPassword())) {
return false;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: {
key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation",
},
type: "warning",
});
if (!confirmed) {
return false;
}
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.deleteCipher(activeUserId);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(
this.cipher.isDeleted ? "permanentlyDeletedItem" : "deletedItem",
),
});
this.onDelete.emit(this.cipher);
} catch (e) {
this.logService.error(e);
}
return true;
}
async restore(): Promise<boolean> {
if (!this.cipher.isDeleted) {
return false;
}
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.restoreCipher(activeUserId);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("restoredItem"),
});
this.onRestore.emit(this.cipher);
} catch (e) {
this.logService.error(e);
}
return true;
}
protected deleteCipher(userId: UserId) {
return this.cipher.isDeleted
? this.cipherService.deleteWithServer(this.cipher.id, userId)
: this.cipherService.softDeleteWithServer(this.cipher.id, userId);
}
protected restoreCipher(userId: UserId) {
return this.cipherService.restoreWithServer(this.cipher.id, userId);
}
protected async promptPassword() {
if (this.cipher.reprompt === CipherRepromptType.None || this.passwordReprompted) {
return true;
}
return (this.passwordReprompted = await this.passwordRepromptService.showPasswordPrompt());
}
}

View File

@@ -0,0 +1,92 @@
<div class="container loading-spinner" *ngIf="!loaded">
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>
<ng-container *ngIf="loaded">
<div class="content">
<cdk-virtual-scroll-viewport
itemSize="42"
minBufferPx="400"
maxBufferPx="600"
*ngIf="ciphers.length"
>
<div class="list">
<button
type="button"
*cdkVirtualFor="let c of ciphers; trackBy: trackByFn"
appStopClick
(click)="selectCipher(c)"
(contextmenu)="rightClickCipher(c)"
title="{{ 'viewItem' | i18n }}"
[ngClass]="{ active: c.id === activeCipherId }"
[attr.aria-pressed]="c.id === activeCipherId"
class="flex-list-item virtual-scroll-item"
>
<app-vault-icon [cipher]="c"></app-vault-icon>
<div class="flex-cipher-list-item">
<span class="text">
<span class="truncate-box">
<span class="truncate">{{ c.name }}</span>
<ng-container *ngIf="c.organizationId">
<i
class="bwi bwi-collection text-muted"
title="{{ 'shared' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "shared" | i18n }}</span>
</ng-container>
<ng-container *ngIf="c.hasAttachments">
<i
class="bwi bwi-paperclip text-muted"
title="{{ 'attachments' | i18n }}"
aria-hidden="true"
></i>
<span class="sr-only">{{ "attachments" | i18n }}</span>
</ng-container>
</span>
</span>
<span *ngIf="c.subTitle" class="detail">{{ c.subTitle }}</span>
</div>
</button>
</div>
</cdk-virtual-scroll-viewport>
<div class="no-items" *ngIf="!ciphers.length">
<img class="no-items-image" aria-hidden="true" />
<p>{{ "noItemsInList" | i18n }}</p>
<ng-container *ngTemplateOutlet="addCipherButton"></ng-container>
</div>
<div class="footer">
<ng-container *ngTemplateOutlet="addCipherButton"></ng-container>
</div>
</div>
</ng-container>
<ng-template #addCipherButton>
<button
type="button"
class="block primary"
bitButton
appA11yTitle="{{ 'addItem' | i18n }}"
[disabled]="deleted"
[bitMenuTriggerFor]="addCipherMenu"
>
<i class="bwi bwi-plus bwi-lg" aria-hidden="true"></i>
</button>
<bit-menu #addCipherMenu>
<button type="button" bitMenuItem (click)="addCipher(CipherType.Login)">
<i class="bwi bwi-globe tw-mr-1" aria-hidden="true"></i>
{{ "typeLogin" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.Card)">
<i class="bwi bwi-credit-card tw-mr-1" aria-hidden="true"></i>
{{ "typeCard" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.Identity)">
<i class="bwi bwi-id-card tw-mr-1" aria-hidden="true"></i>
{{ "typeIdentity" | i18n }}
</button>
<button type="button" bitMenuItem (click)="addCipher(CipherType.SecureNote)">
<i class="bwi bwi-sticky-note tw-mr-1" aria-hidden="true"></i>
{{ "typeSecureNote" | i18n }}
</button>
</bit-menu>
</ng-template>

View File

@@ -0,0 +1,42 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { distinctUntilChanged } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/vault/components/vault-items.component";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { MenuModule } from "@bitwarden/components";
import { SearchBarService } from "../../../app/layout/search/search-bar.service";
@Component({
selector: "app-vault-items-v2",
templateUrl: "vault-items-v2.component.html",
standalone: true,
imports: [MenuModule, CommonModule, JslibModule, ScrollingModule],
})
export class VaultItemsV2Component extends BaseVaultItemsComponent {
constructor(
searchService: SearchService,
private readonly searchBarService: SearchBarService,
cipherService: CipherService,
accountService: AccountService,
) {
super(searchService, cipherService, accountService);
this.searchBarService.searchText$
.pipe(distinctUntilChanged(), takeUntilDestroyed())
.subscribe((searchText) => {
this.searchText = searchText!;
});
}
trackByFn(index: number, c: CipherView): string {
return c.id;
}
}

View File

@@ -0,0 +1,80 @@
<div id="vault" class="vault vault-v2" attr.aria-hidden="{{ showingModal }}">
<app-vault-items-v2
id="items"
class="items"
[activeCipherId]="cipherId"
(onCipherClicked)="viewCipher($event)"
(onCipherRightClicked)="viewCipherMenu($event)"
(onAddCipher)="addCipher($event)"
(onAddCipherOptions)="addCipherOptions()"
>
</app-vault-items-v2>
<div class="details" *ngIf="!!action">
<app-vault-item-footer
id="footer"
#footer
[cipher]="cipher"
[action]="action"
(onEdit)="editCipher($event)"
(onRestore)="restoreCipher()"
(onClone)="cloneCipher($event)"
(onDelete)="deleteCipher()"
(onCancel)="cancelCipher($event)"
></app-vault-item-footer>
<div class="content">
<div class="inner-content">
<div class="box">
<app-cipher-view *ngIf="action === 'view'" [cipher]="cipher" [collections]="collections">
</app-cipher-view>
<vault-cipher-form
#vaultForm
*ngIf="action === 'add' || action === 'edit' || action === 'clone'"
formId="cipherForm"
[config]="config"
(cipherSaved)="savedCipher($event)"
[submitBtn]="footer?.submitBtn"
>
<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>
</div>
</div>
</div>
<div
id="logo"
class="logo"
*ngIf="action !== 'add' && action !== 'edit' && action !== 'view' && action !== 'clone'"
>
<div class="content">
<div class="inner-content">
<img class="logo-image" alt="Bitwarden" aria-hidden="true" />
</div>
</div>
</div>
<div class="left-nav">
<app-vault-filter
class="vault-filters"
[activeFilter]="activeFilter"
(onFilterChange)="applyVaultFilter($event)"
(onAddFolder)="addFolder()"
(onEditFolder)="editFolder($event.id)"
></app-vault-filter>
<app-nav class="nav"></app-nav>
</div>
</div>
<ng-template #folderAddEdit></ng-template>

View File

@@ -0,0 +1,785 @@
import { CommonModule } from "@angular/common";
import {
ChangeDetectorRef,
Component,
NgZone,
OnDestroy,
OnInit,
ViewChild,
ViewContainerRef,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, Subject, takeUntil, switchMap } from "rxjs";
import { filter, map, take } from "rxjs/operators";
import { CollectionView } from "@bitwarden/admin-console/common";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EventType } from "@bitwarden/common/enums";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { CipherId, UserId } 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 { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import {
BadgeModule,
ButtonModule,
DialogService,
ItemModule,
ToastService,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
AttachmentDialogResult,
AttachmentsV2Component,
ChangeLoginPasswordService,
CipherFormConfig,
CipherFormConfigService,
CipherFormGenerationService,
CipherFormMode,
CipherFormModule,
CipherViewComponent,
DecryptionFailureDialogComponent,
DefaultChangeLoginPasswordService,
DefaultCipherFormConfigService,
PasswordRepromptService,
} from "@bitwarden/vault";
import { NavComponent } from "../../../app/layout/nav.component";
import { SearchBarService } from "../../../app/layout/search/search-bar.service";
import { DesktopCredentialGenerationService } from "../../../services/desktop-cipher-form-generator.service";
import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service";
import { invokeMenu, RendererMenuItem } from "../../../utils";
import { FolderAddEditComponent } from "./folder-add-edit.component";
import { ItemFooterComponent } from "./item-footer.component";
import { VaultFilterComponent } from "./vault-filter/vault-filter.component";
import { VaultFilterModule } from "./vault-filter/vault-filter.module";
import { VaultItemsV2Component } from "./vault-items-v2.component";
const BroadcasterSubscriptionId = "VaultComponent";
@Component({
selector: "app-vault",
templateUrl: "vault-v2.component.html",
standalone: true,
imports: [
BadgeModule,
CommonModule,
CipherFormModule,
CipherViewComponent,
ItemFooterComponent,
I18nPipe,
ItemModule,
ButtonModule,
NavComponent,
VaultFilterModule,
VaultItemsV2Component,
],
providers: [
{
provide: CipherFormConfigService,
useClass: DefaultCipherFormConfigService,
},
{
provide: ChangeLoginPasswordService,
useClass: DefaultChangeLoginPasswordService,
},
{
provide: ViewPasswordHistoryService,
useClass: VaultViewPasswordHistoryService,
},
{
provide: PremiumUpgradePromptService,
useClass: DesktopPremiumUpgradePromptService,
},
{ provide: CipherFormGenerationService, useClass: DesktopCredentialGenerationService },
],
})
export class VaultV2Component implements OnInit, OnDestroy {
@ViewChild(VaultItemsV2Component, { static: true })
vaultItemsComponent: VaultItemsV2Component | null = null;
@ViewChild(VaultFilterComponent, { static: true })
vaultFilterComponent: VaultFilterComponent | null = null;
@ViewChild("folderAddEdit", { read: ViewContainerRef, static: true })
folderAddEditModalRef: ViewContainerRef | null = null;
action: CipherFormMode | "view" | null = null;
cipherId: string | null = null;
favorites = false;
type: CipherType | null = null;
folderId: string | null = null;
collectionId: string | null = null;
organizationId: string | null = null;
myVaultOnly = false;
addType: CipherType | undefined = undefined;
addOrganizationId: string | null = null;
addCollectionIds: string[] | null = null;
showingModal = false;
deleted = false;
userHasPremiumAccess = false;
activeFilter: VaultFilter = new VaultFilter();
activeUserId: UserId | null = null;
cipherRepromptId: string | null = null;
cipher: CipherView | null = new CipherView();
collections: CollectionView[] | null = null;
config: CipherFormConfig | null = null;
protected canAccessAttachments$ = this.accountService.activeAccount$.pipe(
filter((account): account is Account => !!account),
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
),
);
private modal: ModalRef | null = null;
private componentIsDestroyed$ = new Subject<boolean>();
constructor(
private route: ActivatedRoute,
private router: Router,
private i18nService: I18nService,
private modalService: ModalService,
private broadcasterService: BroadcasterService,
private changeDetectorRef: ChangeDetectorRef,
private ngZone: NgZone,
private syncService: SyncService,
private messagingService: MessagingService,
private platformUtilsService: PlatformUtilsService,
private eventCollectionService: EventCollectionService,
private totpService: TotpService,
private passwordRepromptService: PasswordRepromptService,
private searchBarService: SearchBarService,
private apiService: ApiService,
private dialogService: DialogService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private toastService: ToastService,
private accountService: AccountService,
private cipherService: CipherService,
private formConfigService: CipherFormConfigService,
private premiumUpgradePromptService: PremiumUpgradePromptService,
) {}
async ngOnInit() {
this.accountService.activeAccount$
.pipe(
filter((account): account is Account => !!account),
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
),
takeUntil(this.componentIsDestroyed$),
)
.subscribe((canAccessPremium: boolean) => {
this.userHasPremiumAccess = canAccessPremium;
});
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
this.ngZone
.run(async () => {
let detectChanges = true;
try {
switch (message.command) {
case "newLogin":
await this.addCipher(CipherType.Login).catch(() => {});
break;
case "newCard":
await this.addCipher(CipherType.Card).catch(() => {});
break;
case "newIdentity":
await this.addCipher(CipherType.Identity).catch(() => {});
break;
case "newSecureNote":
await this.addCipher(CipherType.SecureNote).catch(() => {});
break;
case "focusSearch":
(document.querySelector("#search") as HTMLInputElement)?.select();
detectChanges = false;
break;
case "syncCompleted":
if (this.vaultItemsComponent) {
await this.vaultItemsComponent
.reload(this.activeFilter.buildFilter())
.catch(() => {});
}
if (this.vaultFilterComponent) {
await this.vaultFilterComponent
.reloadCollectionsAndFolders(this.activeFilter)
.catch(() => {});
await this.vaultFilterComponent.reloadOrganizations().catch(() => {});
}
break;
case "modalShown":
this.showingModal = true;
break;
case "modalClosed":
this.showingModal = false;
break;
case "copyUsername": {
if (this.cipher?.login?.username) {
this.copyValue(this.cipher, this.cipher?.login?.username, "username", "Username");
}
break;
}
case "copyPassword": {
if (this.cipher?.login?.password && this.cipher.viewPassword) {
this.copyValue(this.cipher, this.cipher.login.password, "password", "Password");
await this.eventCollectionService
.collect(EventType.Cipher_ClientCopiedPassword, this.cipher.id)
.catch(() => {});
}
break;
}
case "copyTotp": {
if (
this.cipher?.login?.hasTotp &&
(this.cipher.organizationUseTotp || this.userHasPremiumAccess)
) {
const value = await firstValueFrom(
this.totpService.getCode$(this.cipher.login.totp),
).catch(() => null);
if (value) {
this.copyValue(this.cipher, value.code, "verificationCodeTotp", "TOTP");
}
}
break;
}
default:
detectChanges = false;
break;
}
} catch {
// Ignore errors
}
if (detectChanges) {
this.changeDetectorRef.detectChanges();
}
})
.catch(() => {});
});
if (!this.syncService.syncInProgress) {
await this.load().catch(() => {});
}
this.searchBarService.setEnabled(true);
this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault"));
const authRequest = await this.apiService.getLastAuthRequest().catch(() => null);
if (authRequest != null) {
this.messagingService.send("openLoginApproval", {
notificationId: authRequest.id,
});
}
this.activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getUserId),
).catch(() => null);
if (this.activeUserId) {
this.cipherService
.failedToDecryptCiphers$(this.activeUserId)
.pipe(
map((ciphers) => ciphers?.filter((c) => !c.isDeleted) ?? []),
filter((ciphers) => ciphers.length > 0),
take(1),
takeUntil(this.componentIsDestroyed$),
)
.subscribe((ciphers) => {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: ciphers.map((c) => c.id as CipherId),
});
});
}
}
ngOnDestroy() {
this.searchBarService.setEnabled(false);
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
this.componentIsDestroyed$.next(true);
this.componentIsDestroyed$.complete();
}
async load() {
const params = await firstValueFrom(this.route.queryParams).catch();
if (params.cipherId) {
const cipherView = new CipherView();
cipherView.id = params.cipherId;
if (params.action === "clone") {
await this.cloneCipher(cipherView).catch(() => {});
} else if (params.action === "edit") {
await this.editCipher(cipherView).catch(() => {});
} else {
await this.viewCipher(cipherView).catch(() => {});
}
} else if (params.action === "add") {
this.addType = Number(params.addType);
await this.addCipher(this.addType).catch(() => {});
}
this.activeFilter = new VaultFilter({
status: params.deleted ? "trash" : params.favorites ? "favorites" : "all",
cipherType:
params.action === "add" || params.type == null
? undefined
: (parseInt(params.type) as CipherType),
selectedFolderId: params.folderId,
selectedCollectionId: params.selectedCollectionId,
selectedOrganizationId: params.selectedOrganizationId,
myVaultOnly: params.myVaultOnly ?? false,
});
if (this.vaultItemsComponent) {
await this.vaultItemsComponent.reload(this.activeFilter.buildFilter()).catch(() => {});
}
}
async viewCipher(cipher: CipherView) {
if (await this.shouldReprompt(cipher, "view")) {
return;
}
this.cipherId = cipher.id;
this.cipher = cipher;
this.collections =
this.vaultFilterComponent?.collections.fullList.filter((c) =>
cipher.collectionIds.includes(c.id),
) ?? null;
this.action = "view";
await this.go().catch(() => {});
}
async openAttachmentsDialog() {
if (!this.userHasPremiumAccess) {
await this.premiumUpgradePromptService.promptForPremium();
return;
}
const dialogRef = AttachmentsV2Component.open(this.dialogService, {
cipherId: this.cipherId as CipherId,
});
const result = await firstValueFrom(dialogRef.closed).catch(() => null);
if (
result?.action === AttachmentDialogResult.Removed ||
result?.action === AttachmentDialogResult.Uploaded
) {
await this.vaultItemsComponent?.refresh().catch(() => {});
}
}
viewCipherMenu(cipher: CipherView) {
const menu: RendererMenuItem[] = [
{
label: this.i18nService.t("view"),
click: () => {
this.functionWithChangeDetection(() => {
this.viewCipher(cipher).catch(() => {});
});
},
},
];
if (cipher.decryptionFailure) {
invokeMenu(menu);
return;
}
if (!cipher.isDeleted) {
menu.push({
label: this.i18nService.t("edit"),
click: () => {
this.functionWithChangeDetection(() => {
this.editCipher(cipher).catch(() => {});
});
},
});
if (!cipher.organizationId) {
menu.push({
label: this.i18nService.t("clone"),
click: () => {
this.functionWithChangeDetection(() => {
this.cloneCipher(cipher).catch(() => {});
});
},
});
}
}
switch (cipher.type) {
case CipherType.Login:
if (
cipher.login.canLaunch ||
cipher.login.username != null ||
cipher.login.password != null
) {
menu.push({ type: "separator" });
}
if (cipher.login.canLaunch) {
menu.push({
label: this.i18nService.t("launch"),
click: () => this.platformUtilsService.launchUri(cipher.login.launchUri),
});
}
if (cipher.login.username != null) {
menu.push({
label: this.i18nService.t("copyUsername"),
click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"),
});
}
if (cipher.login.password != null && cipher.viewPassword) {
menu.push({
label: this.i18nService.t("copyPassword"),
click: () => {
this.copyValue(cipher, cipher.login.password, "password", "Password");
this.eventCollectionService
.collect(EventType.Cipher_ClientCopiedPassword, cipher.id)
.catch(() => {});
},
});
}
if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) {
menu.push({
label: this.i18nService.t("copyVerificationCodeTotp"),
click: async () => {
const value = await firstValueFrom(
this.totpService.getCode$(cipher.login.totp),
).catch(() => null);
if (value) {
this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP");
}
},
});
}
break;
case CipherType.Card:
if (cipher.card.number != null || cipher.card.code != null) {
menu.push({ type: "separator" });
}
if (cipher.card.number != null) {
menu.push({
label: this.i18nService.t("copyNumber"),
click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"),
});
}
if (cipher.card.code != null) {
menu.push({
label: this.i18nService.t("copySecurityCode"),
click: () => {
this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code");
this.eventCollectionService
.collect(EventType.Cipher_ClientCopiedCardCode, cipher.id)
.catch(() => {});
},
});
}
break;
default:
break;
}
invokeMenu(menu);
}
async shouldReprompt(cipher: CipherView, action: "edit" | "clone" | "view"): Promise<boolean> {
return !(await this.canNavigateAway(action, cipher)) || !(await this.passwordReprompt(cipher));
}
async buildFormConfig(action: CipherFormMode) {
this.config = await this.formConfigService
.buildConfig(action, this.cipherId as CipherId, this.addType)
.catch(() => null);
}
async editCipher(cipher: CipherView) {
if (await this.shouldReprompt(cipher, "edit")) {
return;
}
this.cipherId = cipher.id;
this.cipher = cipher;
await this.buildFormConfig("edit");
this.action = "edit";
await this.go().catch(() => {});
}
async cloneCipher(cipher: CipherView) {
if (await this.shouldReprompt(cipher, "clone")) {
return;
}
this.cipherId = cipher.id;
this.cipher = cipher;
await this.buildFormConfig("clone");
this.action = "clone";
await this.go().catch(() => {});
}
async addCipher(type: CipherType) {
this.addType = type || this.activeFilter.cipherType;
this.cipherId = null;
await this.buildFormConfig("add");
this.action = "add";
this.prefillCipherFromFilter();
await this.go().catch(() => {});
}
addCipherOptions() {
const menu: RendererMenuItem[] = [
{
label: this.i18nService.t("typeLogin"),
click: () => this.addCipherWithChangeDetection(CipherType.Login),
},
{
label: this.i18nService.t("typeCard"),
click: () => this.addCipherWithChangeDetection(CipherType.Card),
},
{
label: this.i18nService.t("typeIdentity"),
click: () => this.addCipherWithChangeDetection(CipherType.Identity),
},
{
label: this.i18nService.t("typeSecureNote"),
click: () => this.addCipherWithChangeDetection(CipherType.SecureNote),
},
];
invokeMenu(menu);
}
async savedCipher(cipher: CipherView) {
this.cipherId = null;
this.action = "view";
await this.vaultItemsComponent?.refresh().catch(() => {});
this.cipherId = cipher.id;
this.cipher = cipher;
if (this.activeUserId) {
await this.cipherService.clearCache(this.activeUserId).catch(() => {});
}
await this.vaultItemsComponent?.load(this.activeFilter.buildFilter()).catch(() => {});
await this.go().catch(() => {});
await this.vaultItemsComponent?.refresh().catch(() => {});
}
async deleteCipher() {
this.cipherId = null;
this.cipher = null;
this.action = null;
await this.go().catch(() => {});
await this.vaultItemsComponent?.refresh().catch(() => {});
}
async restoreCipher() {
this.cipherId = null;
this.action = null;
await this.go().catch(() => {});
await this.vaultItemsComponent?.refresh().catch(() => {});
}
async cancelCipher(cipher: CipherView) {
this.cipherId = cipher.id;
this.cipher = cipher;
this.action = this.cipherId != null ? "view" : null;
await this.go().catch(() => {});
}
async applyVaultFilter(vaultFilter: VaultFilter) {
this.searchBarService.setPlaceholderText(
this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)),
);
this.activeFilter = vaultFilter;
await this.vaultItemsComponent
?.reload(this.activeFilter.buildFilter(), vaultFilter.status === "trash")
.catch(() => {});
await this.go().catch(() => {});
}
private calculateSearchBarLocalizationString(vaultFilter: VaultFilter): string {
if (vaultFilter.status === "favorites") {
return "searchFavorites";
}
if (vaultFilter.status === "trash") {
return "searchTrash";
}
if (vaultFilter.cipherType != null) {
return "searchType";
}
if (vaultFilter.selectedFolderId != null && vaultFilter.selectedFolderId !== "none") {
return "searchFolder";
}
if (vaultFilter.selectedCollectionId != null) {
return "searchCollection";
}
if (vaultFilter.selectedOrganizationId != null) {
return "searchOrganization";
}
if (vaultFilter.myVaultOnly) {
return "searchMyVault";
}
return "searchVault";
}
async addFolder() {
this.messagingService.send("newFolder");
}
async editFolder(folderId: string) {
if (this.modal != null) {
this.modal.close();
}
if (this.folderAddEditModalRef == null) {
return;
}
const [modal, childComponent] = await this.modalService
.openViewRef(
FolderAddEditComponent,
this.folderAddEditModalRef,
(comp) => (comp.folderId = folderId),
)
.catch(() => [null, null] as any);
this.modal = modal;
if (childComponent) {
childComponent.onSavedFolder.subscribe(async (folder: FolderView) => {
this.modal?.close();
await this.vaultFilterComponent
?.reloadCollectionsAndFolders(this.activeFilter)
.catch(() => {});
});
childComponent.onDeletedFolder.subscribe(async (folder: FolderView) => {
this.modal?.close();
await this.vaultFilterComponent
?.reloadCollectionsAndFolders(this.activeFilter)
.catch(() => {});
});
}
if (this.modal) {
this.modal.onClosed.pipe(takeUntilDestroyed()).subscribe(() => {
this.modal = null;
});
}
}
private dirtyInput(): boolean {
return (
(this.action === "add" || this.action === "edit" || this.action === "clone") &&
document.querySelectorAll("vault-cipher-form .ng-dirty").length > 0
);
}
private async wantsToSaveChanges(): Promise<boolean> {
const confirmed = await this.dialogService
.openSimpleDialog({
title: { key: "unsavedChangesTitle" },
content: { key: "unsavedChangesConfirmation" },
type: "warning",
})
.catch(() => false);
return !confirmed;
}
private async go(queryParams: any = null) {
if (queryParams == null) {
queryParams = {
action: this.action,
cipherId: this.cipherId,
favorites: this.favorites ? true : null,
type: this.type,
folderId: this.folderId,
collectionId: this.collectionId,
deleted: this.deleted ? true : null,
organizationId: this.organizationId,
myVaultOnly: this.myVaultOnly,
};
}
this.router
.navigate([], {
relativeTo: this.route,
queryParams: queryParams,
replaceUrl: true,
})
.catch(() => {});
}
private addCipherWithChangeDetection(type: CipherType) {
this.functionWithChangeDetection(() => this.addCipher(type).catch(() => {}));
}
private copyValue(cipher: CipherView, value: string, labelI18nKey: string, aType: string) {
this.functionWithChangeDetection(() => {
(async () => {
if (
cipher.reprompt !== CipherRepromptType.None &&
this.passwordRepromptService.protectedFields().includes(aType) &&
!(await this.passwordReprompt(cipher))
) {
return;
}
this.platformUtilsService.copyToClipboard(value);
this.toastService.showToast({
variant: "info",
title: undefined,
message: this.i18nService.t("valueCopied", this.i18nService.t(labelI18nKey)),
});
if (this.action === "view") {
this.messagingService.send("minimizeOnCopy");
}
})().catch(() => {});
});
}
private functionWithChangeDetection(func: () => void) {
this.ngZone.run(() => {
func();
this.changeDetectorRef.detectChanges();
});
}
private prefillCipherFromFilter() {
if (this.activeFilter.selectedCollectionId != null && this.vaultFilterComponent != null) {
const collections = this.vaultFilterComponent.collections.fullList.filter(
(c) => c.id === this.activeFilter.selectedCollectionId,
);
if (collections.length > 0) {
this.addOrganizationId = collections[0].organizationId;
this.addCollectionIds = [this.activeFilter.selectedCollectionId];
}
} else if (this.activeFilter.selectedOrganizationId) {
this.addOrganizationId = this.activeFilter.selectedOrganizationId;
}
if (this.activeFilter.selectedFolderId && this.activeFilter.selectedFolder) {
this.folderId = this.activeFilter.selectedFolderId;
}
}
private async canNavigateAway(action: string, cipher?: CipherView) {
if (this.action === action && (!cipher || this.cipherId === cipher.id)) {
return false;
} else if (this.dirtyInput() && (await this.wantsToSaveChanges())) {
return false;
}
return true;
}
private async passwordReprompt(cipher: CipherView) {
if (cipher.reprompt === CipherRepromptType.None) {
this.cipherRepromptId = null;
return true;
}
if (this.cipherRepromptId === cipher.id) {
return true;
}
const repromptResult = await this.passwordRepromptService.showPasswordPrompt();
if (repromptResult) {
this.cipherRepromptId = cipher.id;
}
return repromptResult;
}
}

View File

@@ -69,6 +69,8 @@ import {
ToastService,
} from "@bitwarden/components";
import {
AttachmentDialogResult,
AttachmentsV2Component,
CipherFormConfig,
CipherFormConfigService,
CollectionAssignmentResult,
@@ -92,10 +94,6 @@ import {
} from "../../../vault/components/vault-item-dialog/vault-item-dialog.component";
import { VaultItemEvent } from "../../../vault/components/vault-items/vault-item-event";
import { VaultItemsModule } from "../../../vault/components/vault-items/vault-items.module";
import {
AttachmentDialogResult,
AttachmentsV2Component,
} from "../../../vault/individual-vault/attachments-v2.component";
import {
BulkDeleteDialogResult,
openBulkDeleteDialog,

View File

@@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EmergencyAccessId } from "@bitwarden/common/types/guid";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
@@ -21,8 +22,6 @@ import {
DefaultChangeLoginPasswordService,
} from "@bitwarden/vault";
import { WebViewPasswordHistoryService } from "../../../../vault/services/web-view-password-history.service";
export interface EmergencyViewDialogParams {
/** The cipher being viewed. */
cipher: CipherView;
@@ -42,7 +41,7 @@ class PremiumUpgradePromptNoop implements PremiumUpgradePromptService {
standalone: true,
imports: [ButtonModule, CipherViewComponent, DialogModule, CommonModule, JslibModule],
providers: [
{ provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService },
{ provide: ViewPasswordHistoryService, useClass: VaultViewPasswordHistoryService },
{ provide: PremiumUpgradePromptService, useClass: PremiumUpgradePromptNoop },
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },
],

View File

@@ -7,6 +7,7 @@ import { firstValueFrom, Subject, switchMap } from "rxjs";
import { map } from "rxjs/operators";
import { CollectionView } from "@bitwarden/admin-console/common";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -39,6 +40,9 @@ import {
ToastService,
} from "@bitwarden/components";
import {
AttachmentDialogCloseResult,
AttachmentDialogResult,
AttachmentsV2Component,
ChangeLoginPasswordService,
CipherFormComponent,
CipherFormConfig,
@@ -50,16 +54,10 @@ import {
} from "@bitwarden/vault";
import { SharedModule } from "../../../shared/shared.module";
import {
AttachmentDialogCloseResult,
AttachmentDialogResult,
AttachmentsV2Component,
} from "../../individual-vault/attachments-v2.component";
import { WebVaultPremiumUpgradePromptService } from "../../../vault/services/web-premium-upgrade-prompt.service";
import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service";
import { RoutedVaultFilterModel } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model";
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";
@@ -135,7 +133,7 @@ export enum VaultItemDialogResult {
],
providers: [
{ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService },
{ provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService },
{ provide: ViewPasswordHistoryService, useClass: VaultViewPasswordHistoryService },
{ provide: CipherFormGenerationService, useClass: WebCipherFormGenerationService },
RoutedVaultFilterService,
{ provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService },

View File

@@ -21,6 +21,7 @@ import {
ItemModule,
} from "@bitwarden/components";
import {
AttachmentsV2Component,
CipherAttachmentsComponent,
CipherFormConfig,
CipherFormGenerationService,
@@ -31,8 +32,6 @@ import {
import { SharedModule } from "../../shared/shared.module";
import { WebCipherFormGenerationService } from "../services/web-cipher-form-generation.service";
import { AttachmentsV2Component } from "./attachments-v2.component";
/**
* The result of the AddEditCipherDialogV2 component.
*/

View File

@@ -69,6 +69,9 @@ import { DialogRef, DialogService, Icons, ToastService } from "@bitwarden/compon
import {
AddEditFolderDialogComponent,
AddEditFolderDialogResult,
AttachmentDialogCloseResult,
AttachmentDialogResult,
AttachmentsV2Component,
CipherFormConfig,
CollectionAssignmentResult,
DecryptionFailureDialogComponent,
@@ -96,11 +99,6 @@ 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 {
AttachmentDialogCloseResult,
AttachmentDialogResult,
AttachmentsV2Component,
} from "./attachments-v2.component";
import {
BulkDeleteDialogResult,
openBulkDeleteDialog,

View File

@@ -5,6 +5,7 @@ import { Component, EventEmitter, Inject, OnInit } from "@angular/core";
import { firstValueFrom, map, Observable } from "rxjs";
import { CollectionView } from "@bitwarden/admin-console/common";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -21,8 +22,8 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
DialogConfig,
AsyncActionsModule,
DialogModule,
DialogService,
@@ -31,8 +32,7 @@ import {
import { CipherViewComponent } from "@bitwarden/vault";
import { SharedModule } from "../../shared/shared.module";
import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service";
import { WebViewPasswordHistoryService } from "../services/web-view-password-history.service";
import { WebVaultPremiumUpgradePromptService } from "../../vault/services/web-premium-upgrade-prompt.service";
export interface ViewCipherDialogParams {
cipher: CipherView;
@@ -74,7 +74,7 @@ export interface ViewCipherDialogCloseResult {
standalone: true,
imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule],
providers: [
{ provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService },
{ provide: ViewPasswordHistoryService, useClass: VaultViewPasswordHistoryService },
{ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService },
],
})

View File

@@ -3,17 +3,16 @@ import { TestBed } from "@angular/core/testing";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { openPasswordHistoryDialog } from "@bitwarden/vault";
import { openPasswordHistoryDialog } from "../individual-vault/password-history.component";
import { VaultViewPasswordHistoryService } from "./view-password-history.service";
import { WebViewPasswordHistoryService } from "./web-view-password-history.service";
jest.mock("../individual-vault/password-history.component", () => ({
jest.mock("@bitwarden/vault", () => ({
openPasswordHistoryDialog: jest.fn(),
}));
describe("WebViewPasswordHistoryService", () => {
let service: WebViewPasswordHistoryService;
describe("VaultViewPasswordHistoryService", () => {
let service: VaultViewPasswordHistoryService;
let dialogService: DialogService;
beforeEach(async () => {
@@ -23,13 +22,13 @@ describe("WebViewPasswordHistoryService", () => {
await TestBed.configureTestingModule({
providers: [
WebViewPasswordHistoryService,
VaultViewPasswordHistoryService,
{ provide: DialogService, useValue: mockDialogService },
Overlay,
],
}).compileComponents();
service = TestBed.inject(WebViewPasswordHistoryService);
service = TestBed.inject(VaultViewPasswordHistoryService);
dialogService = TestBed.inject(DialogService);
});

View File

@@ -3,14 +3,13 @@ import { Injectable } from "@angular/core";
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { openPasswordHistoryDialog } from "../individual-vault/password-history.component";
import { openPasswordHistoryDialog } from "@bitwarden/vault";
/**
* This service is used to display the password history dialog in the web vault.
* This service is used to display the password history dialog in the vault.
*/
@Injectable()
export class WebViewPasswordHistoryService implements ViewPasswordHistoryService {
export class VaultViewPasswordHistoryService implements ViewPasswordHistoryService {
constructor(private dialogService: DialogService) {}
/**

View File

@@ -18,6 +18,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@Directive()
@@ -25,13 +26,14 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
@Input() activeCipherId: string = null;
@Output() onCipherClicked = new EventEmitter<CipherView>();
@Output() onCipherRightClicked = new EventEmitter<CipherView>();
@Output() onAddCipher = new EventEmitter();
@Output() onAddCipher = new EventEmitter<CipherType | undefined>();
@Output() onAddCipherOptions = new EventEmitter();
loaded = false;
ciphers: CipherView[] = [];
deleted = false;
organization: Organization;
CipherType = CipherType;
protected searchPending = false;
@@ -109,8 +111,8 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
this.onCipherRightClicked.emit(cipher);
}
addCipher() {
this.onAddCipher.emit();
addCipher(type?: CipherType) {
this.onAddCipher.emit(type);
}
addCipherOptions() {

View File

@@ -57,6 +57,7 @@ export enum FeatureFlag {
VaultBulkManagementAction = "vault-bulk-management-action",
SecurityTasks = "security-tasks",
CipherKeyEncryption = "cipher-key-encryption",
PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms",
EndUserNotifications = "pm-10609-end-user-notifications",
/* Platform */
@@ -110,6 +111,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.PM18520_UpdateDesktopCipherForm]: FALSE,
[FeatureFlag.EndUserNotifications]: FALSE,
/* Auth */

View File

@@ -132,22 +132,22 @@ export class IdentitySectionComponent implements OnInit {
private initFromExistingCipher(existingIdentity: IdentityView) {
this.identityForm.patchValue({
firstName: this.initialValues.firstName ?? existingIdentity.firstName,
middleName: this.initialValues.middleName ?? existingIdentity.middleName,
lastName: this.initialValues.lastName ?? existingIdentity.lastName,
company: this.initialValues.company ?? existingIdentity.company,
ssn: this.initialValues.ssn ?? existingIdentity.ssn,
passportNumber: this.initialValues.passportNumber ?? existingIdentity.passportNumber,
licenseNumber: this.initialValues.licenseNumber ?? existingIdentity.licenseNumber,
email: this.initialValues.email ?? existingIdentity.email,
phone: this.initialValues.phone ?? existingIdentity.phone,
address1: this.initialValues.address1 ?? existingIdentity.address1,
address2: this.initialValues.address2 ?? existingIdentity.address2,
address3: this.initialValues.address3 ?? existingIdentity.address3,
city: this.initialValues.city ?? existingIdentity.city,
state: this.initialValues.state ?? existingIdentity.state,
postalCode: this.initialValues.postalCode ?? existingIdentity.postalCode,
country: this.initialValues.country ?? existingIdentity.country,
firstName: this.initialValues?.firstName ?? existingIdentity.firstName,
middleName: this.initialValues?.middleName ?? existingIdentity.middleName,
lastName: this.initialValues?.lastName ?? existingIdentity.lastName,
company: this.initialValues?.company ?? existingIdentity.company,
ssn: this.initialValues?.ssn ?? existingIdentity.ssn,
passportNumber: this.initialValues?.passportNumber ?? existingIdentity.passportNumber,
licenseNumber: this.initialValues?.licenseNumber ?? existingIdentity.licenseNumber,
email: this.initialValues?.email ?? existingIdentity.email,
phone: this.initialValues?.phone ?? existingIdentity.phone,
address1: this.initialValues?.address1 ?? existingIdentity.address1,
address2: this.initialValues?.address2 ?? existingIdentity.address2,
address3: this.initialValues?.address3 ?? existingIdentity.address3,
city: this.initialValues?.city ?? existingIdentity.city,
state: this.initialValues?.state ?? existingIdentity.state,
postalCode: this.initialValues?.postalCode ?? existingIdentity.postalCode,
country: this.initialValues?.country ?? existingIdentity.country,
});
}

View File

@@ -4,10 +4,16 @@ import { CommonModule } from "@angular/common";
import { Component, Inject } from "@angular/core";
import { CipherId } from "@bitwarden/common/types/guid";
import { DialogRef, DIALOG_DATA, DialogService } from "@bitwarden/components";
import { CipherAttachmentsComponent } from "@bitwarden/vault";
import {
ButtonModule,
DialogModule,
DialogService,
DIALOG_DATA,
DialogRef,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { SharedModule } from "../../shared/shared.module";
import { CipherAttachmentsComponent } from "../../cipher-form/components/attachments/cipher-attachments.component";
export interface AttachmentsDialogParams {
cipherId: CipherId;
@@ -33,7 +39,7 @@ export interface AttachmentDialogCloseResult {
selector: "app-vault-attachments-v2",
templateUrl: "attachments-v2.component.html",
standalone: true,
imports: [CommonModule, SharedModule, CipherAttachmentsComponent],
imports: [ButtonModule, CommonModule, DialogModule, I18nPipe, CipherAttachmentsComponent],
})
export class AttachmentsV2Component {
cipherId: CipherId;

View File

@@ -124,7 +124,8 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
}
const { username, password, totp, fido2Credentials } = this.cipher.login;
return username || password || totp || fido2Credentials;
return username || password || totp || fido2Credentials?.length > 0;
}
get hasAutofill() {

View File

@@ -1,2 +1,3 @@
export * from "./cipher-view.component";
export * from "./attachments/attachments-v2.component";
export { CipherAttachmentsComponent } from "../cipher-form/components/attachments/cipher-attachments.component";

View File

@@ -5,17 +5,17 @@ import { Inject, Component } from "@angular/core";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
DIALOG_DATA,
DialogConfig,
DialogRef,
AsyncActionsModule,
ButtonModule,
DialogModule,
DialogService,
DIALOG_DATA,
DialogRef,
DialogConfig,
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { PasswordHistoryViewComponent } from "@bitwarden/vault";
import { SharedModule } from "../../shared/shared.module";
/**
* The parameters for the password history dialog.
*/
@@ -31,10 +31,11 @@ export interface ViewPasswordHistoryDialogParams {
templateUrl: "password-history.component.html",
standalone: true,
imports: [
ButtonModule,
CommonModule,
AsyncActionsModule,
I18nPipe,
DialogModule,
SharedModule,
PasswordHistoryViewComponent,
],
})

View File

@@ -19,6 +19,7 @@ export { PasswordHistoryViewComponent } from "./components/password-history-view
export { NewDeviceVerificationNoticePageOneComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-one.component";
export { NewDeviceVerificationNoticePageTwoComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-two.component";
export { DecryptionFailureDialogComponent } from "./components/decryption-failure-dialog/decryption-failure-dialog.component";
export { openPasswordHistoryDialog } from "./components/password-history/password-history.component";
export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.component";
export * from "./components/carousel";