diff --git a/apps/desktop/src/app/app.module.ts b/apps/desktop/src/app/app.module.ts index 52a52ffd225..bcd7400294b 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -26,7 +26,6 @@ import { UserVerificationComponent } from "./components/user-verification.compon import { AccountSwitcherComponent } from "./layout/account-switcher.component"; import { HeaderComponent } from "./layout/header.component"; import { NavComponent } from "./layout/nav.component"; -import { SearchComponent } from "./layout/search/search.component"; import { SharedModule } from "./shared/shared.module"; @NgModule({ @@ -51,7 +50,6 @@ import { SharedModule } from "./shared/shared.module"; ColorPasswordCountPipe, HeaderComponent, PremiumComponent, - SearchComponent, ], providers: [ SshAgentService, diff --git a/apps/desktop/src/app/layout/desktop-layout.component.html b/apps/desktop/src/app/layout/desktop-layout.component.html index f9921ac11ef..cb969f573fc 100644 --- a/apps/desktop/src/app/layout/desktop-layout.component.html +++ b/apps/desktop/src/app/layout/desktop-layout.component.html @@ -1,4 +1,4 @@ - + diff --git a/apps/desktop/src/app/layout/header.component.html b/apps/desktop/src/app/layout/header.component.html index 53ce02fb685..4ea9c4ca274 100644 --- a/apps/desktop/src/app/layout/header.component.html +++ b/apps/desktop/src/app/layout/header.component.html @@ -1,4 +1,3 @@
-
diff --git a/apps/desktop/src/app/layout/search/search.component.html b/apps/desktop/src/app/layout/search/search.component.html index 515385c2076..5d546769151 100644 --- a/apps/desktop/src/app/layout/search/search.component.html +++ b/apps/desktop/src/app/layout/search/search.component.html @@ -1,11 +1,4 @@ - +@if (state.enabled) { + + +} diff --git a/apps/desktop/src/app/layout/search/search.component.ts b/apps/desktop/src/app/layout/search/search.component.ts index c0b088a13d9..dec646f3c84 100644 --- a/apps/desktop/src/app/layout/search/search.component.ts +++ b/apps/desktop/src/app/layout/search/search.component.ts @@ -1,10 +1,12 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { CommonModule } from "@angular/common"; import { Component, OnDestroy, OnInit } from "@angular/core"; -import { UntypedFormControl } from "@angular/forms"; +import { ReactiveFormsModule, UntypedFormControl } from "@angular/forms"; import { Subscription } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AutofocusDirective, SearchModule } from "@bitwarden/components"; import { SearchBarService, SearchBarState } from "./search-bar.service"; @@ -13,7 +15,7 @@ import { SearchBarService, SearchBarState } from "./search-bar.service"; @Component({ selector: "app-search", templateUrl: "search.component.html", - standalone: false, + imports: [CommonModule, ReactiveFormsModule, AutofocusDirective, SearchModule], }) export class SearchComponent implements OnInit, OnDestroy { state: SearchBarState; diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 0ce98b8c62b..f9e33c0a805 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -11,6 +11,9 @@ "favorites": { "message": "Favorites" }, + "unfavorite": { + "message": "Unfavorite" + }, "types": { "message": "Types" }, @@ -47,6 +50,21 @@ "addItem": { "message": "Add item" }, + "addLogin": { + "message": "Add login" + }, + "addCard": { + "message": "Add card" + }, + "addIdentity": { + "message": "Add identity" + }, + "addSecureNote": { + "message": "Add secure note" + }, + "addSshKey": { + "message": "Add SSH key" + }, "shared": { "message": "Shared" }, @@ -84,6 +102,21 @@ "viewItem": { "message": "View item" }, + "viewLogin": { + "message": "View login" + }, + "viewCard": { + "message": "View card" + }, + "viewIdentity": { + "message": "View identity" + }, + "viewSecureNote": { + "message": "View secure note" + }, + "viewSshKey": { + "message": "View SSH key" + }, "name": { "message": "Name" }, @@ -100,70 +133,6 @@ } } }, - "deletionDateDescV2": { - "message": "The Send will be permanently deleted on this date.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "fileToShare": { - "message": "File to share" - }, - "hideTextByDefault": { - "message": "Hide text by default" - }, - "hideYourEmail": { - "message": "Hide your email address from viewers." - }, - "limitSendViews": { - "message": "Limit views" - }, - "limitSendViewsCount": { - "message": "$ACCESSCOUNT$ views left", - "description": "Displayed under the limit views field on Send", - "placeholders": { - "accessCount": { - "content": "$1", - "example": "2" - } - } - }, - "limitSendViewsHint": { - "message": "No one can view this Send after the limit is reached.", - "description": "Displayed under the limit views field on Send" - }, - "privateNote": { - "message": "Private note" - }, - "sendDetails": { - "message": "Send details", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendPasswordDescV3": { - "message": "Add an optional password for recipients to access this Send.", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, - "sendTypeTextToShare": { - "message": "Text to share" - }, - "newItemHeaderTextSend": { - "message": "New Text Send", - "description": "Header for new text send" - }, - "newItemHeaderFileSend": { - "message": "New File Send", - "description": "Header for new file send" - }, - "editItemHeaderTextSend": { - "message": "Edit Text Send", - "description": "Header for edit text send" - }, - "editItemHeaderFileSend": { - "message": "Edit File Send", - "description": "Header for edit file send" - }, - "deleteSendPermanentConfirmation": { - "message": "Are you sure you want to permanently delete this Send?", - "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." - }, "new": { "message": "New", "description": "for adding new items" @@ -183,6 +152,21 @@ "editItem": { "message": "Edit item" }, + "editLogin": { + "message": "Edit login" + }, + "editCard": { + "message": "Edit card" + }, + "editIdentity": { + "message": "Edit identity" + }, + "editSecureNote": { + "message": "Edit secure note" + }, + "editSshKey": { + "message": "Edit SSH key" + }, "emailAddress": { "message": "Email address" }, @@ -590,6 +574,12 @@ "editedItem": { "message": "Item saved" }, + "itemAddedToFavorites": { + "message": "Item added to favorites" + }, + "itemRemovedFromFavorites": { + "message": "Item removed from favorites" + }, "deleteItem": { "message": "Delete item" }, @@ -605,6 +595,15 @@ "deletedItem": { "message": "Item sent to trash" }, + "deletedCollectionId": { + "message": "Deleted collection $ID$.", + "placeholders": { + "id": { + "content": "$1", + "example": "Server Passwords" + } + } + }, "overwritePasswordConfirmation": { "message": "Are you sure you want to overwrite the current password?" }, @@ -815,6 +814,15 @@ "deletedFolder": { "message": "Folder deleted" }, + "editInfo": { + "message": "Edit info" + }, + "access": { + "message": "Access" + }, + "editAccess": { + "message": "Edit access" + }, "loginOrCreateNewAccount": { "message": "Log in or create a new account to access your secure vault." }, @@ -1556,6 +1564,21 @@ "unknown": { "message": "Unknown" }, + "unknownCipher": { + "message": "Unknown item, you may need to request permission to access this item." + }, + "copyAddress": { + "message": "Copy address" + }, + "copyPhone": { + "message": "Copy phone" + }, + "copyNote": { + "message": "Copy note" + }, + "copyVerificationCode": { + "message": "Copy verification code" + }, "copyUsername": { "message": "Copy username" }, @@ -2057,6 +2080,24 @@ "clone": { "message": "Clone" }, + "cloneItem": { + "message": "Clone item" + }, + "cloneLogin": { + "message": "Clone login" + }, + "cloneCard": { + "message": "Clone card" + }, + "cloneIdentity": { + "message": "Clone identity" + }, + "cloneSecureNote": { + "message": "Clone secure note" + }, + "cloneSshKey": { + "message": "Clone SSH key" + }, "passwordGeneratorPolicyInEffect": { "message": "One or more organization policies are affecting your generator settings." }, @@ -2080,9 +2121,33 @@ "message": "Trash", "description": "Noun: a special folder to hold deleted items" }, + "trashCleanupWarning": { + "message": "Items that have been in trash more than 30 days will be automatically deleted." + }, + "trashCleanupWarningSelfHosted": { + "message": "Items that have been in trash for a while will be automatically deleted." + }, "searchTrash": { "message": "Search trash" }, + "searchArchive": { + "message": "Search archive" + }, + "searchLogin": { + "message": "Search login" + }, + "searchCard": { + "message": "Search card" + }, + "searchIdentity": { + "message": "Search identity" + }, + "searchSecureNote": { + "message": "Search secure note" + }, + "searchSshKey": { + "message": "Search SSH key" + }, "permanentlyDeleteItem": { "message": "Permanently delete item" }, @@ -2092,9 +2157,6 @@ "permanentlyDeletedItem": { "message": "Item permanently deleted" }, - "archivedItemRestored": { - "message": "Archived item restored" - }, "restoredItem": { "message": "Item restored" }, @@ -2379,6 +2441,9 @@ "message": "Edit Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." }, + "me": { + "message": "Me" + }, "myVault": { "message": "My vault" }, @@ -3780,6 +3845,93 @@ "collection": { "message": "Collection" }, + "editCollection": { + "message": "Edit collection" + }, + "nestCollectionUnder": { + "message": "Nest collection under" + }, + "collectionInfo": { + "message": "Collection info" + }, + "grantCollectionAccess": { + "message": "Grant groups or members access to this collection." + }, + "permission": { + "message": "Permission" + }, + "viewItems": { + "message": "View items" + }, + "viewItemsHidePass": { + "message": "View items, hidden passwords" + }, + "editItems": { + "message": "Edit items" + }, + "editItemsHidePass": { + "message": "Edit items, hidden passwords" + }, + "manageCollection": { + "message": "Manage collection" + }, + "selectGroupsAndMembers": { + "message": "Select groups and members" + }, + "newCollection": { + "message": "New collection" + }, + "externalId": { + "message": "External ID" + }, + "externalIdDesc": { + "message": "External ID is an unencrypted reference used by the Bitwarden Directory Connector and API." + }, + "noCollection": { + "message": "No collection" + }, + "deleted": { + "message": "Deleted" + }, + "readOnlyCollectionAccess": { + "message": "You do not have access to manage this collection." + }, + "grantManageCollectionWarningTitle": { + "message": "Missing Manage Collection Permissions" + }, + "grantManageCollectionWarning": { + "message": "Grant Manage collection permissions to allow full collection management including deletion of collection." + }, + "grantCollectionAccessMembersOnly": { + "message": "Grant members access to this collection." + }, + "adminCollectionAccess": { + "message": "Administrators can access and manage collections." + }, + "managePermissionRequired": { + "message": "At least one member or group must have can manage permission." + }, + "userPermissionOverrideHelperDesc": { + "message": "Permissions set for a member will replace permissions set by that member's group." + }, + "noMembersOrGroupsAdded": { + "message": "No members or groups added" + }, + "memberColumnHeader": { + "message": "Member" + }, + "selectMembers": { + "message": "Select members" + }, + "noMembersAdded": { + "message": "No members added" + }, + "groupSlashMemberColumnHeader": { + "message": "Group/Member" + }, + "deleteCollectionConfirmation": { + "message": "Are you sure you want to delete this collection?" + }, "lastPassYubikeyDesc": { "message": "Insert the YubiKey associated with your LastPass account into your computer's USB port, then touch its button." }, @@ -4087,6 +4239,9 @@ "missingWebsite": { "message": "Missing website" }, + "missingPermissions": { + "message": "You lack the necessary permissions to perform this action." + }, "cannotRemoveViewOnlyCollections": { "message": "You cannot remove collections with View only permissions: $COLLECTIONS$", "placeholders": { @@ -4400,8 +4555,8 @@ "archiveItem": { "message": "Archive item" }, - "archiveItemDialogContent": { - "message": "Once archived, this item will be excluded from search results and autofill suggestions." + "archiveItemConfirmDesc": { + "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" }, "unArchiveAndSave": { "message": "Unarchive and save" @@ -4588,4 +4743,4 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" } -} +} \ No newline at end of file diff --git a/apps/desktop/src/scss/buttons.scss b/apps/desktop/src/scss/buttons.scss index 52de33c8d2c..31a212cf9f7 100644 --- a/apps/desktop/src/scss/buttons.scss +++ b/apps/desktop/src/scss/buttons.scss @@ -1,7 +1,6 @@ @import "variables.scss"; .btn, -.vault .footer button, .modal-footer button { border-radius: $border-radius; padding: 7px 15px; diff --git a/apps/desktop/src/scss/vault.scss b/apps/desktop/src/scss/vault.scss index 88216a2b926..ab526eda01b 100644 --- a/apps/desktop/src/scss/vault.scss +++ b/apps/desktop/src/scss/vault.scss @@ -28,9 +28,8 @@ app-root { > .items { order: 2; - width: 28%; + width: 50%; min-width: 200px; - max-width: 350px; border-right: 1px solid #000000; @include themify($themes) { diff --git a/apps/desktop/src/vault/app/vault-v3/cipher-form/item-footer.component.html b/apps/desktop/src/vault/app/vault-v3/cipher-form/item-footer.component.html new file mode 100644 index 00000000000..add7d605bd4 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/cipher-form/item-footer.component.html @@ -0,0 +1,50 @@ +
+ @if (!cipher.decryptionFailure) { + + @if (!cipher.isDeleted && action === "view") { + + } + @if (cipher.isDeleted && cipher.permissions.restore) { + + } + } + @if (hasFooterAction) { +
+ +
+ } +
diff --git a/apps/desktop/src/vault/app/vault-v3/cipher-form/item-footer.component.ts b/apps/desktop/src/vault/app/vault-v3/cipher-form/item-footer.component.ts new file mode 100644 index 00000000000..11f0648993f --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/cipher-form/item-footer.component.ts @@ -0,0 +1,204 @@ +import { CommonModule } from "@angular/common"; +import { + Input, + Output, + EventEmitter, + Component, + OnInit, + ViewChild, + OnChanges, + SimpleChanges, + input, +} from "@angular/core"; +import { combineLatest, firstValueFrom, switchMap } 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 { UserId } from "@bitwarden/common/types/guid"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +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, + IconButtonModule, + DialogService, + ToastService, +} from "@bitwarden/components"; +import { ArchiveCipherUtilitiesService, PasswordRepromptService } from "@bitwarden/vault"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "app-vault-item-footer", + templateUrl: "item-footer.component.html", + imports: [ButtonModule, IconButtonModule, CommonModule, JslibModule], +}) +export class ItemFooterComponent implements OnInit, OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input({ required: true }) cipher: CipherView = new CipherView(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() collectionId: string | null = null; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input({ required: true }) action: string = "view"; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() masterPasswordAlreadyPrompted: boolean = false; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref + @Output() onEdit = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref + @Output() onClone = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref + @Output() onDelete = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref + @Output() onRestore = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref + @Output() onCancel = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref + @Output() onArchiveToggle = new EventEmitter(); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild("submitBtn", { static: false }) submitBtn: ButtonComponent | null = null; + + readonly submitButtonText = input(this.i18nService.t("save")); + + activeUserId: UserId | null = null; + passwordReprompted: boolean = false; + + protected showArchiveButton = false; + protected showUnarchiveButton = 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, + protected cipherArchiveService: CipherArchiveService, + protected archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, + ) {} + + async ngOnInit() { + this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + this.passwordReprompted = this.masterPasswordAlreadyPrompted; + await this.checkArchiveState(); + } + + async ngOnChanges(changes: SimpleChanges) { + if (changes.cipher) { + await this.checkArchiveState(); + } + } + + 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); + } + + protected get hasFooterAction() { + return ( + this.showArchiveButton || + this.showUnarchiveButton || + (this.cipher.permissions?.delete && (this.action === "edit" || this.action === "view")) + ); + } + + cancel() { + this.onCancel.emit(this.cipher); + } + + async delete() { + this.onDelete.emit(this.cipher); + } + + async restore() { + this.onRestore.emit(this.cipher); + } + + 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()); + } + + protected async archive() { + await this.archiveCipherUtilitiesService.archiveCipher(this.cipher); + this.onArchiveToggle.emit(); + } + + protected async unarchive() { + await this.archiveCipherUtilitiesService.unarchiveCipher(this.cipher); + this.onArchiveToggle.emit(); + } + + private async checkArchiveState() { + const cipherCanBeArchived = !this.cipher.isDeleted; + const [userCanArchive, hasArchiveFlagEnabled] = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((id) => + combineLatest([ + this.cipherArchiveService.userCanArchive$(id), + this.cipherArchiveService.hasArchiveFlagEnabled$, + ]), + ), + ), + ); + + this.showArchiveButton = + cipherCanBeArchived && userCanArchive && this.action === "view" && !this.cipher.isArchived; + + // A user should always be able to unarchive an archived item + this.showUnarchiveButton = + hasArchiveFlagEnabled && this.action === "view" && this.cipher.isArchived; + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/organization-badge/organization-name-badge.component.html b/apps/desktop/src/vault/app/vault-v3/organization-badge/organization-name-badge.component.html new file mode 100644 index 00000000000..4fd9539f049 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/organization-badge/organization-name-badge.component.html @@ -0,0 +1,13 @@ + diff --git a/apps/desktop/src/vault/app/vault-v3/organization-badge/organization-name-badge.component.ts b/apps/desktop/src/vault/app/vault-v3/organization-badge/organization-name-badge.component.ts new file mode 100644 index 00000000000..9f24bb1cab6 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/organization-badge/organization-name-badge.component.ts @@ -0,0 +1,74 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Component, Input, OnChanges } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Unassigned } from "@bitwarden/common/admin-console/models/collections"; +import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; +import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { BadgeModule } from "@bitwarden/components"; +import { OrganizationId } from "@bitwarden/sdk-internal"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "app-org-badge", + templateUrl: "organization-name-badge.component.html", + imports: [RouterModule, JslibModule, BadgeModule], +}) +export class OrganizationNameBadgeComponent implements OnChanges { + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() organizationId?: OrganizationId | string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() organizationName: string; + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() disabled: boolean; + + // Need a separate variable or we get weird behavior when used as part of cdk virtual scrolling + name: string; + color: string; + textColor: string; + isMe: boolean; + + constructor( + private i18nService: I18nService, + private avatarService: AvatarService, + private tokenService: TokenService, + ) {} + + // ngOnChanges is required since this component might be reused as part of + // cdk virtual scrolling + async ngOnChanges() { + this.isMe = this.organizationName == null || this.organizationName === ""; + + if (this.isMe) { + this.name = this.i18nService.t("me"); + this.color = await firstValueFrom(this.avatarService.avatarColor$); + if (this.color == null) { + const userId = await this.tokenService.getUserId(); + if (userId != null) { + this.color = Utils.stringToColor(userId); + } else { + const userName = + (await this.tokenService.getName()) ?? (await this.tokenService.getEmail()); + this.color = Utils.stringToColor(userName.toUpperCase()); + } + } + } else { + this.name = this.organizationName; + this.color = Utils.stringToColor(this.organizationName.toUpperCase()); + } + this.textColor = Utils.pickTextColorBasedOnBgColor(this.color, 135, true) + "!important"; + } + + get organizationIdLink() { + return this.organizationId ?? Unassigned; + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/pipes/pipes.module.ts b/apps/desktop/src/vault/app/vault-v3/pipes/pipes.module.ts new file mode 100644 index 00000000000..f9b2ef6d2a2 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/pipes/pipes.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from "@angular/core"; + +import { GetOrgNameFromIdPipe } from "@bitwarden/vault"; + +@NgModule({ + declarations: [GetOrgNameFromIdPipe], + exports: [GetOrgNameFromIdPipe], +}) +export class PipesModule {} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.html b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.html new file mode 100644 index 00000000000..341cc105ef0 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.html @@ -0,0 +1,309 @@ + + +
+ + @if (hasAttachments()) { + + {{ "attachments" | i18n }} + @if (showFixOldAttachments()) { + + {{ "attachmentsNeedFix" | i18n }} + } + } +
+
+ {{ subtitle() }} + +@if (showOwner()) { + + + + +} +@if (showGroups()) { + +} +@if (viewingOrgVault()) { + +

+ {{ permissionText() }} +

+ +} + + @if (decryptionFailure()) { + + + @if (canDeleteCipher()) { + + } + + } @else { + @if (canLaunch()) { + + + + } + + + @if (isLoginCipher()) { + + @if (cipher().viewPassword) { + + } + + } + + @if (isCardCipher()) { + + + } + + @if (isIdentityCipher()) { + + + + + } + + @if (isSecureNoteCipher()) { + + } + + + + @if (canLaunch()) { + + + {{ "launch" | i18n }} + + } + @if (isLoginCipher()) { + + @if (cipher().viewPassword) { + + } + + } + + @if (isCardCipher()) { + + + } + + @if (isIdentityCipher()) { + + + + + } + + @if (isSecureNoteCipher()) { + + } + @if (showMenuDivider()) { + + } + @if (!viewingOrgVault()) { + @if (showFavorite()) { + + } + } + @if (canEditCipher()) { + + } + @if (showAttachments()) { + + } + @if (showClone()) { + + } + @if (showAssignToCollections()) { + + } + @if (showEventLogs()) { + + } + @if (showArchiveButton()) { + @if (userCanArchive()) { + + } + @if (!userCanArchive()) { + + } + } + + @if (showUnArchiveButton()) { + + } + + @if (isDeleted() && canRestoreCipher()) { + + } + @if (canDeleteCipher()) { + + } + + } + diff --git a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.ts new file mode 100644 index 00000000000..20913b4e079 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.ts @@ -0,0 +1,308 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Component, HostListener, ViewChild, computed, input, output, inject } from "@angular/core"; + +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { MenuTriggerForDirective } from "@bitwarden/components"; +import { VaultItemEvent } from "@bitwarden/vault"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "tr[appVaultCipherRow]", + templateUrl: "vault-cipher-row.component.html", + standalone: false, +}) +export class VaultCipherRowComponent { + protected RowHeightClass = `tw-h-[75px]`; + + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @ViewChild(MenuTriggerForDirective, { static: false }) menuTrigger: MenuTriggerForDirective; + + protected readonly disabled = input(); + protected readonly cipher = input(); + protected readonly showOwner = input(); + protected readonly showGroups = input(); + protected readonly showPremiumFeatures = input(); + protected readonly useEvents = input(); + protected readonly cloneable = input(); + protected readonly organizations = input(); + protected readonly viewingOrgVault = input(); + protected readonly canEditCipher = input(); + protected readonly canAssignCollections = input(); + protected readonly canManageCollection = input(); + /** + * uses new permission delete logic from PM-15493 + */ + protected readonly canDeleteCipher = input(); + /** + * uses new permission restore logic from PM-15493 + */ + protected readonly canRestoreCipher = input(); + /** + * user has archive permissions + */ + protected readonly userCanArchive = input(); + /** Archive feature is enabled */ + readonly archiveEnabled = input.required(); + /** + * Enforce Org Data Ownership Policy Status + */ + protected readonly enforceOrgDataOwnershipPolicy = input(); + protected readonly onEvent = output>(); + + protected CipherType = CipherType; + + private i18nService = inject(I18nService); + + // Archive button will not show in Admin Console + protected readonly showArchiveButton = computed(() => { + if (!this.archiveEnabled() || this.viewingOrgVault()) { + return false; + } + + return ( + !CipherViewLikeUtils.isArchived(this.cipher()) && + !CipherViewLikeUtils.isDeleted(this.cipher()) + ); + }); + + // If item is archived always show unarchive button, even if user is not premium + protected readonly showUnArchiveButton = computed(() => { + if (!this.archiveEnabled()) { + return false; + } + + return CipherViewLikeUtils.isArchived(this.cipher()); + }); + + protected readonly clickAction = computed(() => { + if (this.decryptionFailure()) { + return "showFailedToDecrypt"; + } + + return "view"; + }); + + protected readonly showTotpCopyButton = computed(() => { + const login = CipherViewLikeUtils.getLogin(this.cipher()); + + const hasTotp = login?.totp ?? false; + + return hasTotp && (this.cipher().organizationUseTotp || this.showPremiumFeatures()); + }); + + protected readonly showFixOldAttachments = computed(() => { + return this.cipher().hasOldAttachments && this.cipher().organizationId == null; + }); + + protected readonly hasAttachments = computed(() => { + return CipherViewLikeUtils.hasAttachments(this.cipher()); + }); + + // Do not show attachments button if: + // item is archived AND user is not premium user + protected readonly showAttachments = computed(() => { + if (CipherViewLikeUtils.isArchived(this.cipher()) && !this.userCanArchive()) { + return false; + } + return this.canEditCipher() || this.hasAttachments(); + }); + + protected readonly canLaunch = computed(() => { + return CipherViewLikeUtils.canLaunch(this.cipher()); + }); + + protected readonly launchUri = computed(() => { + return CipherViewLikeUtils.getLaunchUri(this.cipher()); + }); + + protected readonly subtitle = computed(() => { + return CipherViewLikeUtils.subtitle(this.cipher()); + }); + + protected readonly isDeleted = computed(() => { + return CipherViewLikeUtils.isDeleted(this.cipher()); + }); + + protected readonly decryptionFailure = computed(() => { + return CipherViewLikeUtils.decryptionFailure(this.cipher()); + }); + + protected readonly showFavorite = computed(() => { + if (CipherViewLikeUtils.isArchived(this.cipher()) && !this.userCanArchive()) { + return false; + } + return true; + }); + + // Do Not show Assign to Collections option if item is archived + protected readonly showAssignToCollections = computed(() => { + if (CipherViewLikeUtils.isArchived(this.cipher())) { + return false; + } + return ( + this.organizations()?.length && + this.canAssignCollections() && + !CipherViewLikeUtils.isDeleted(this.cipher()) + ); + }); + + // Do NOT show clone option if: + // item is archived AND user is not premium user + // item is archived AND enforce org data ownership policy is on + protected readonly showClone = computed(() => { + if ( + CipherViewLikeUtils.isArchived(this.cipher()) && + (!this.userCanArchive() || this.enforceOrgDataOwnershipPolicy()) + ) { + return false; + } + return this.cloneable() && !CipherViewLikeUtils.isDeleted(this.cipher()); + }); + + protected readonly showEventLogs = computed(() => { + return this.useEvents() && this.cipher().organizationId; + }); + + protected readonly isLoginCipher = computed(() => { + return ( + CipherViewLikeUtils.getType(this.cipher()) === this.CipherType.Login && + !CipherViewLikeUtils.isDeleted(this.cipher()) && + !CipherViewLikeUtils.isArchived(this.cipher()) + ); + }); + + protected readonly permissionText = computed(() => { + if (!this.cipher().organizationId || this.cipher().collectionIds.length === 0) { + return this.i18nService.t("manageCollection"); + } + + return this.i18nService.t("noAccess"); + }); + + protected readonly hasVisibleLoginOptions = computed(() => { + return ( + this.isLoginCipher() && + (CipherViewLikeUtils.hasCopyableValue(this.cipher(), "username") || + (this.cipher().viewPassword && + CipherViewLikeUtils.hasCopyableValue(this.cipher(), "password")) || + this.showTotpCopyButton() || + this.canLaunch()) + ); + }); + + protected readonly isCardCipher = computed(() => { + return CipherViewLikeUtils.getType(this.cipher()) === this.CipherType.Card && !this.isDeleted(); + }); + + protected readonly hasVisibleCardOptions = computed(() => { + return ( + this.isCardCipher() && + (CipherViewLikeUtils.hasCopyableValue(this.cipher(), "cardNumber") || + CipherViewLikeUtils.hasCopyableValue(this.cipher(), "securityCode")) + ); + }); + + protected readonly isIdentityCipher = computed(() => { + if (CipherViewLikeUtils.isArchived(this.cipher()) && !this.userCanArchive()) { + return false; + } + return ( + CipherViewLikeUtils.getType(this.cipher()) === this.CipherType.Identity && !this.isDeleted() + ); + }); + + protected readonly hasVisibleIdentityOptions = computed(() => { + return ( + this.isIdentityCipher() && + (CipherViewLikeUtils.hasCopyableValue(this.cipher(), "address") || + CipherViewLikeUtils.hasCopyableValue(this.cipher(), "email") || + CipherViewLikeUtils.hasCopyableValue(this.cipher(), "username") || + CipherViewLikeUtils.hasCopyableValue(this.cipher(), "phone")) + ); + }); + + protected readonly isSecureNoteCipher = computed(() => { + return ( + CipherViewLikeUtils.getType(this.cipher()) === this.CipherType.SecureNote && + !(this.isDeleted() && this.canRestoreCipher()) + ); + }); + + protected readonly hasVisibleSecureNoteOptions = computed(() => { + return ( + this.isSecureNoteCipher() && CipherViewLikeUtils.hasCopyableValue(this.cipher(), "secureNote") + ); + }); + + protected readonly showMenuDivider = computed(() => { + return ( + this.hasVisibleLoginOptions() || + this.hasVisibleCardOptions() || + this.hasVisibleIdentityOptions() || + this.hasVisibleSecureNoteOptions() + ); + }); + + protected clone() { + this.onEvent.emit({ type: "clone", item: this.cipher() }); + } + + protected events() { + this.onEvent.emit({ type: "viewEvents", item: this.cipher() }); + } + + protected archive() { + this.onEvent.emit({ type: "archive", items: [this.cipher()] }); + } + + protected unarchive() { + this.onEvent.emit({ type: "unarchive", items: [this.cipher()] }); + } + + protected restore() { + this.onEvent.emit({ type: "restore", items: [this.cipher()] }); + } + + protected deleteCipher() { + this.onEvent.emit({ type: "delete", items: [{ cipher: this.cipher() }] }); + } + + protected attachments() { + this.onEvent.emit({ type: "viewAttachments", item: this.cipher() }); + } + + protected assignToCollections() { + this.onEvent.emit({ type: "assignToCollections", items: [this.cipher()] }); + } + + protected toggleFavorite() { + this.onEvent.emit({ + type: "toggleFavorite", + item: this.cipher(), + }); + } + + protected editCipher() { + this.onEvent.emit({ type: "editCipher", item: this.cipher() }); + } + + @HostListener("contextmenu", ["$event"]) + protected onRightClick(event: MouseEvent) { + if (event.shiftKey && event.ctrlKey) { + return; + } + + if (!this.disabled() && this.menuTrigger) { + this.menuTrigger.toggleMenuOnRightClick(event); + } + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-collection-row.component.html b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-collection-row.component.html new file mode 100644 index 00000000000..0616942efe1 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-collection-row.component.html @@ -0,0 +1,38 @@ + +
+ + +
+ +@if (showOwner()) { + + + + +} + diff --git a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-collection-row.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-collection-row.component.ts new file mode 100644 index 00000000000..97a3d033271 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-collection-row.component.ts @@ -0,0 +1,26 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { Component, input } from "@angular/core"; + +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "tr[appVaultCollectionRow]", + templateUrl: "vault-collection-row.component.html", + standalone: false, +}) +export class VaultCollectionRowComponent { + protected RowHeightClass = `tw-h-[75px]`; + protected DefaultCollectionType = CollectionTypes.DefaultUserCollection; + + protected readonly disabled = input(); + protected readonly collection = input(); + protected readonly showOwner = input(); + protected readonly organizations = input(); +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-items/vault-items.module.ts b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-items.module.ts new file mode 100644 index 00000000000..5068bd2ba26 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-items.module.ts @@ -0,0 +1,46 @@ +import { ScrollingModule } from "@angular/cdk/scrolling"; +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + BadgeModule, + IconButtonModule, + IconModule, + LinkModule, + MenuModule, + ScrollLayoutDirective, + TableModule, +} from "@bitwarden/components"; +import { CopyCipherFieldDirective } from "@bitwarden/vault"; + +import { OrganizationNameBadgeComponent } from "../organization-badge/organization-name-badge.component"; +import { PipesModule } from "../pipes/pipes.module"; + +import { VaultCipherRowComponent } from "./vault-cipher-row.component"; +import { VaultCollectionRowComponent } from "./vault-collection-row.component"; + +@NgModule({ + imports: [ + CommonModule, + RouterModule, + ScrollingModule, + JslibModule, + TableModule, + MenuModule, + IconButtonModule, + IconModule, + LinkModule, + BadgeModule, + CopyCipherFieldDirective, + ScrollLayoutDirective, + PremiumBadgeComponent, + OrganizationNameBadgeComponent, + PipesModule, + ], + declarations: [VaultCipherRowComponent, VaultCollectionRowComponent], + exports: [VaultCipherRowComponent, VaultCollectionRowComponent], +}) +export class VaultItemsModule {} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-list.component.html b/apps/desktop/src/vault/app/vault-v3/vault-list.component.html new file mode 100644 index 00000000000..e4183f6a2d4 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-list.component.html @@ -0,0 +1,93 @@ + +
+ + + +
+
+ +
+ + + + + + {{ "name" | i18n }} + + @if (showOwner()) { + + {{ "owner" | i18n }} + + } + + {{ "options" | i18n }} + + + + + + @if (item.collection) { + + } + + @if ( + item.cipher && (!addAccessToggle() || (addAccessToggle() && addAccessStatus() !== 1)) + ) { + + } + + + +
diff --git a/apps/desktop/src/vault/app/vault-v3/vault-list.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-list.component.ts new file mode 100644 index 00000000000..7191fa0667c --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-list.component.ts @@ -0,0 +1,309 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { SelectionModel } from "@angular/cdk/collections"; +import { ScrollingModule } from "@angular/cdk/scrolling"; +import { AsyncPipe } from "@angular/common"; +import { Component, input, output, effect, inject, computed } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Observable, of, switchMap } from "rxjs"; + +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { + RestrictedCipherType, + RestrictedItemTypesService, +} from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { + SortDirection, + TableDataSource, + TableModule, + MenuModule, + ButtonModule, + IconButtonModule, +} from "@bitwarden/components"; +import { OrganizationId } from "@bitwarden/sdk-internal"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { NewCipherMenuComponent, VaultItem, VaultItemEvent } from "@bitwarden/vault"; + +import { DesktopHeaderComponent } from "../../../app/layout/header/desktop-header.component"; +import { SearchBarService } from "../../../app/layout/search/search-bar.service"; +import { SearchComponent } from "../../../app/layout/search/search.component"; + +import { VaultItemsModule } from "./vault-items/vault-items.module"; + +// Fixed manual row height required due to how cdk-virtual-scroll works +export const RowHeight = 75; +export const RowHeightClass = `tw-h-[75px]`; + +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "app-vault-list", + templateUrl: "vault-list.component.html", + imports: [ + ScrollingModule, + TableModule, + I18nPipe, + AsyncPipe, + MenuModule, + ButtonModule, + IconButtonModule, + VaultItemsModule, + SearchComponent, + DesktopHeaderComponent, + NewCipherMenuComponent, + ], +}) +export class VaultListComponent { + protected RowHeight = RowHeight; + + protected readonly disabled = input(); + protected readonly showOwner = input(); + protected readonly useEvents = input(); + protected readonly showPremiumFeatures = input(); + // Encompasses functionality only available from the organization vault context + protected readonly showAdminActions = input(false); + protected readonly allOrganizations = input([]); + protected readonly allCollections = input([]); + protected readonly showPermissionsColumn = input(false); + protected readonly viewingOrgVault = input(); + protected readonly addAccessStatus = input(); + protected readonly addAccessToggle = input(); + protected readonly activeCollection = input(); + protected readonly userCanArchive = input(); + protected readonly enforceOrgDataOwnershipPolicy = input(); + + protected readonly ciphers = input([]); + + protected readonly collections = input([]); + + protected onEvent = output>(); + protected onAddCipher = output(); + protected onAddFolder = output(); + + protected cipherAuthorizationService = inject(CipherAuthorizationService); + protected restrictedItemTypesService = inject(RestrictedItemTypesService); + protected cipherArchiveService = inject(CipherArchiveService); + private searchBarService = inject(SearchBarService); + private i18nService = inject(I18nService); + + protected dataSource = new TableDataSource>(); + protected selection = new SelectionModel>(true, [], true); + private restrictedTypes: RestrictedCipherType[] = []; + + protected archiveFeatureEnabled$ = this.cipherArchiveService.hasArchiveFlagEnabled$; + + constructor() { + // Enable the search bar + this.searchBarService.setEnabled(true); + this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); + + this.restrictedItemTypesService.restricted$.pipe(takeUntilDestroyed()).subscribe((types) => { + this.restrictedTypes = types; + this.refreshItems(); + }); + + // Refresh items when collections or ciphers change + effect(() => { + this.collections(); + this.ciphers(); + this.refreshItems(); + }); + } + + protected readonly showExtraColumn = computed(() => this.showOwner()); + + protected event(event: VaultItemEvent) { + this.onEvent.emit(event); + } + + protected addCipher(type: CipherType) { + this.onAddCipher.emit(type); + } + + protected addFolder() { + this.onAddFolder.emit(); + } + + protected canClone$(vaultItem: VaultItem): Observable { + return this.restrictedItemTypesService.restricted$.pipe( + switchMap((restrictedTypes) => { + // This will check for restrictions from org policies before allowing cloning. + const isItemRestricted = restrictedTypes.some( + (rt) => rt.cipherType === CipherViewLikeUtils.getType(vaultItem.cipher), + ); + if (isItemRestricted) { + return of(false); + } + return this.cipherAuthorizationService.canCloneCipher$( + vaultItem.cipher, + this.showAdminActions(), + ); + }), + ); + } + + protected canEditCipher(cipher: C) { + if (cipher.organizationId == null) { + return true; + } + + const organization = this.allOrganizations().find((o) => o.id === cipher.organizationId); + return (organization.canEditAllCiphers && this.viewingOrgVault()) || cipher.edit; + } + + protected canAssignCollections(cipher: C) { + const organization = this.allOrganizations().find((o) => o.id === cipher.organizationId); + const editableCollections = this.allCollections().filter((c) => !c.readOnly); + + return ( + (organization?.canEditAllCiphers && this.viewingOrgVault()) || + (CipherViewLikeUtils.canAssignToCollections(cipher) && editableCollections.length > 0) + ); + } + + protected canManageCollection(cipher: C) { + // If the cipher is not part of an organization (personal item), user can manage it + if (cipher.organizationId == null) { + return true; + } + + // Check for admin access in AC vault + if (this.showAdminActions()) { + const organization = this.allOrganizations().find((o) => o.id === cipher.organizationId); + // If the user is an admin, they can delete an unassigned cipher + if (cipher.collectionIds.length === 0) { + return organization?.canEditUnmanagedCollections === true; + } + + if ( + organization?.permissions.editAnyCollection || + (organization?.allowAdminAccessToAllCollectionItems && organization.isAdmin) + ) { + return true; + } + } + + if (this.activeCollection()) { + return this.activeCollection().manage === true; + } + + return this.allCollections() + .filter((c) => cipher.collectionIds.includes(c.id as any)) + .some((collection) => collection.manage); + } + + private refreshItems() { + const collections: VaultItem[] = + this.collections()?.map((collection) => ({ collection })) || []; + const ciphers: VaultItem[] = this.ciphers() + .filter( + (cipher) => + !this.restrictedItemTypesService.isCipherRestricted(cipher, this.restrictedTypes), + ) + .map((cipher) => ({ cipher })); + const items: VaultItem[] = [].concat(collections).concat(ciphers); + + this.dataSource.data = items; + } + + protected assignToCollections() { + this.event({ + type: "assignToCollections", + items: this.selection.selected + .filter((item) => item.cipher !== undefined) + .map((item) => item.cipher), + }); + } + + protected showAssignToCollections(): boolean { + // When the user doesn't belong to an organization, hide assign to collections + if (this.allOrganizations().length === 0) { + return false; + } + + if (this.selection.selected.length === 0) { + return false; + } + + const hasPersonalItems = this.hasPersonalItems(); + const uniqueCipherOrgIds = this.getUniqueOrganizationIds(); + const hasEditableCollections = this.allCollections().some((collection) => { + return !collection.readOnly; + }); + + // Return false if items are from different organizations + if (uniqueCipherOrgIds.size > 1) { + return false; + } + + // If all selected items are personal, return based on personal items + if (uniqueCipherOrgIds.size === 0 && hasEditableCollections) { + return hasPersonalItems; + } + + const [orgId] = uniqueCipherOrgIds; + const organization = this.allOrganizations().find((o) => o.id === orgId); + + const canEditOrManageAllCiphers = organization?.canEditAllCiphers && this.viewingOrgVault(); + + const collectionNotSelected = + this.selection.selected.filter((item) => item.collection).length === 0; + + return ( + (canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && + collectionNotSelected && + hasEditableCollections + ); + } + + /** + * Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name. + */ + protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + return this.compareNames(a, b); + }; + + protected sortByOwner = (a: VaultItem, b: VaultItem, direction: SortDirection) => { + const getOwnerName = (item: VaultItem): string => { + if (item.cipher) { + return (item.cipher.organizationId as string) || ""; + } else if (item.collection) { + return (item.collection.organizationId as string) || ""; + } + return ""; + }; + + const ownerA = getOwnerName(a); + const ownerB = getOwnerName(b); + + return ownerA.localeCompare(ownerB); + }; + + private compareNames(a: VaultItem, b: VaultItem): number { + const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name; + return getName(a)?.localeCompare(getName(b)) ?? -1; + } + + private hasPersonalItems(): boolean { + return this.selection.selected.some(({ cipher }) => !cipher?.organizationId); + } + + private allCiphersHaveEditAccess(): boolean { + return this.selection.selected + .filter(({ cipher }) => cipher) + .every(({ cipher }) => cipher?.edit && cipher?.viewPassword); + } + + private getUniqueOrganizationIds(): Set { + return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? [])); + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.html b/apps/desktop/src/vault/app/vault-v3/vault.component.html index a9a25f57994..46d8dcf2101 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.html +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.html @@ -1,70 +1,93 @@ -
- + - -
- -
-
-
- - - - - - - + + + + + } +
+
+
+
+ + + +
+
+ } + @if (action() !== "add" && action() !== "edit" && action() !== "view" && action() !== "clone") { + -
- + } diff --git a/apps/desktop/src/vault/app/vault-v3/vault.component.ts b/apps/desktop/src/vault/app/vault-v3/vault.component.ts index c104f76ff2d..3cd43c7fefd 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.ts @@ -1,8 +1,16 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; +import { + ChangeDetectorRef, + Component, + computed, + inject, + NgZone, + OnDestroy, + OnInit, + signal, + ViewChild, +} from "@angular/core"; +import { ActivatedRoute, Router, Params } from "@angular/router"; import { firstValueFrom, Subject, @@ -10,40 +18,55 @@ import { switchMap, lastValueFrom, Observable, - from, + debounceTime, + distinctUntilChanged, + BehaviorSubject, + combineLatest, } from "rxjs"; -import { filter, map, take } from "rxjs/operators"; +import { filter, map, take, first, shareReplay, concatMap, tap } from "rxjs/operators"; import { CollectionService } from "@bitwarden/admin-console/common"; import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; +import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service"; -import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; +import { CollectionView, Unassigned } from "@bitwarden/common/admin-console/models/collections"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { + getNestedCollectionTree, + getFlatCollectionTree, +} from "@bitwarden/common/admin-console/utils"; 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { getByIds } from "@bitwarden/common/platform/misc"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SyncService } from "@bitwarden/common/platform/sync"; -import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { CipherId, OrganizationId, UserId, CollectionId } from "@bitwarden/common/types/guid"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; -import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service"; import { CipherViewLike, CipherViewLikeUtils, @@ -57,6 +80,7 @@ import { ToastService, CopyClickListener, COPY_CLICK_LISTENER, + IconButtonModule, } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; import { @@ -79,15 +103,22 @@ import { VaultFilter, VaultFilterServiceAbstraction as VaultFilterService, RoutedVaultFilterBridgeService, + RoutedVaultFilterService, + RoutedVaultFilterModel, + createFilterFunction, + All, + VaultItem, + VaultItemEvent, } from "@bitwarden/vault"; +import { DesktopHeaderComponent } from "../../../app/layout/header/desktop-header.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 { AssignCollectionsDesktopComponent } from "../vault/assign-collections"; -import { ItemFooterComponent } from "../vault/item-footer.component"; -import { VaultItemsV2Component } from "../vault/vault-items-v2.component"; + +import { ItemFooterComponent } from "./cipher-form/item-footer.component"; +import { VaultListComponent } from "./vault-list.component"; const BroadcasterSubscriptionId = "VaultComponent"; @@ -105,8 +136,10 @@ const BroadcasterSubscriptionId = "VaultComponent"; I18nPipe, ItemModule, ButtonModule, + IconButtonModule, PremiumBadgeComponent, - VaultItemsV2Component, + VaultListComponent, + DesktopHeaderComponent, ], providers: [ { @@ -132,40 +165,132 @@ const BroadcasterSubscriptionId = "VaultComponent"; }, ], }) -export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @ViewChild(VaultItemsV2Component, { static: true }) - vaultItemsComponent: VaultItemsV2Component | null = null; +export class VaultComponent + implements OnInit, OnDestroy, CopyClickListener +{ + private route = inject(ActivatedRoute); + private router = inject(Router); + private i18nService = inject(I18nService); + private broadcasterService = inject(BroadcasterService); + private changeDetectorRef = inject(ChangeDetectorRef); + private ngZone = inject(NgZone); + private syncService = inject(SyncService); + private messagingService = inject(MessagingService); + private platformUtilsService = inject(PlatformUtilsService); + private eventCollectionService = inject(EventCollectionService); + private totpService = inject(TotpService); + private passwordRepromptService = inject(PasswordRepromptService); + private searchBarService = inject(SearchBarService); + private dialogService = inject(DialogService); + private billingAccountProfileStateService = inject(BillingAccountProfileStateService); + private toastService = inject(ToastService); + private accountService = inject(AccountService); + private cipherService = inject(CipherService); + private formConfigService = inject(CipherFormConfigService); + private premiumUpgradePromptService = inject(PremiumUpgradePromptService); + private collectionService = inject(CollectionService); + private logService = inject(LogService); + private organizationService = inject(OrganizationService); + private folderService = inject(FolderService); + private restrictedItemTypesService = inject(RestrictedItemTypesService); + private cipherArchiveService = inject(CipherArchiveService); + private policyService = inject(PolicyService); + private archiveCipherUtilitiesService = inject(ArchiveCipherUtilitiesService); + private routedVaultFilterBridgeService = inject(RoutedVaultFilterBridgeService); + private vaultFilterService = inject(VaultFilterService); + private routedVaultFilterService = inject(RoutedVaultFilterService); + private searchService = inject(SearchService); + private searchPipe = inject(SearchPipe); + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals // eslint-disable-next-line @angular-eslint/prefer-signals @ViewChild(CipherFormComponent) - cipherFormComponent: CipherFormComponent | null = null; - - action: CipherFormMode | "view" | null = null; - cipherId: string | null = null; - favorites = false; - type: CipherType | null = null; - folderId: string | null | undefined = 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 cipherFormComponent: CipherFormComponent | null = null; + protected readonly action = signal(null); + protected cipherId: string | null = null; + private folderId: string | null | undefined = null; + private addType: CipherType | undefined = undefined; + private addOrganizationId: string | null = null; + private addCollectionIds: string[] | null = null; + protected activeFilter: VaultFilter = new VaultFilter(); + private activeUserId: UserId | null = null; + protected cipherRepromptId: string | null = null; + protected readonly cipher = signal(new CipherView()); + protected collections: CollectionView[] | null = null; + protected config: CipherFormConfig | null = null; /** Tracks the disabled status of the edit cipher form */ protected formDisabled: boolean = false; + /** Gets the appropriate translation key for the header based on cipher type */ + protected readonly headerTitleKey = computed(() => { + const currentAction = this.action(); + const currentCipher = this.cipher(); + + if (currentAction === "view" && currentCipher) { + switch (currentCipher.type) { + case CipherType.Login: + return "viewLogin"; + case CipherType.Card: + return "viewCard"; + case CipherType.Identity: + return "viewIdentity"; + case CipherType.SecureNote: + return "viewSecureNote"; + case CipherType.SshKey: + return "viewSshKey"; + default: + return "viewItem"; + } + } else if (currentAction === "add") { + switch (this.addType) { + case CipherType.Login: + return "addLogin"; + case CipherType.Card: + return "addCard"; + case CipherType.Identity: + return "addIdentity"; + case CipherType.SecureNote: + return "addSecureNote"; + case CipherType.SshKey: + return "addSshKey"; + default: + return "addItem"; + } + } else if (currentAction === "edit") { + switch (currentCipher?.type) { + case CipherType.Login: + return "editLogin"; + case CipherType.Card: + return "editCard"; + case CipherType.Identity: + return "editIdentity"; + case CipherType.SecureNote: + return "editSecureNote"; + case CipherType.SshKey: + return "editSshKey"; + default: + return "editItem"; + } + } else if (currentAction === "clone") { + switch (currentCipher?.type) { + case CipherType.Login: + return "cloneLogin"; + case CipherType.Card: + return "cloneCard"; + case CipherType.Identity: + return "cloneIdentity"; + case CipherType.SecureNote: + return "cloneSecureNote"; + case CipherType.SshKey: + return "cloneSshKey"; + default: + return "cloneItem"; + } + } + return "viewItem"; + }); + private organizations$: Observable = this.accountService.activeAccount$.pipe( map((a) => a?.id), filterOutNullish(), @@ -179,227 +304,417 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { ), ); - private componentIsDestroyed$ = new Subject(); - private allOrganizations: Organization[] = []; - private allCollections: CollectionView[] = []; - private filteredCollections: CollectionView[] = []; + protected performingInitialLoad = true; + protected refreshing = false; + protected processingEvent = false; + protected filter: RoutedVaultFilterModel = {}; + protected canAccessPremium: boolean; + protected allOrganizations: Organization[] = []; + protected allCollections: CollectionView[] = []; + protected filteredCollections: CollectionView[] = []; + protected collectionsToDisplay: CollectionView[] = []; + protected selectedCollection: TreeNode | undefined; + protected currentSearchText$: Observable = this.route.queryParams.pipe( + map((queryParams) => queryParams.search), + ); + private userId$ = this.accountService.activeAccount$.pipe(getUserId); + protected ciphers: C[] = []; + protected filteredCiphers: C[] = []; + protected isEmpty: boolean; + private refresh$ = new BehaviorSubject(null); + private destroy$ = new Subject(); - constructor( - private route: ActivatedRoute, - private router: Router, - private i18nService: I18nService, - 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 dialogService: DialogService, - private billingAccountProfileStateService: BillingAccountProfileStateService, - private toastService: ToastService, - private accountService: AccountService, - private cipherService: CipherService, - private formConfigService: CipherFormConfigService, - private premiumUpgradePromptService: PremiumUpgradePromptService, - private collectionService: CollectionService, - private organizationService: OrganizationService, - private folderService: FolderService, - private authRequestService: AuthRequestServiceAbstraction, - private cipherArchiveService: CipherArchiveService, - private policyService: PolicyService, - private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService, - private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService, - private vaultFilterService: VaultFilterService, - ) {} + protected userCanArchive$ = this.userId$.pipe( + switchMap((userId) => { + return this.cipherArchiveService.userCanArchive$(userId); + }), + ); + protected enforceOrgDataOwnershipPolicy$ = this.userId$.pipe( + switchMap((userId) => + this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId), + ), + ); 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; + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + this.activeUserId = activeUserId; + + // Clear cipher selection on page load/reload to prevent flash of content + const currentParams = await firstValueFrom(this.route.queryParams); + if (currentParams.itemId || currentParams.cipherId) { + await this.router.navigate([], { + queryParams: { itemId: null, cipherId: null, action: null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + } + + const firstSetup$ = this.route.queryParams.pipe( + first(), + switchMap(async (params: Params) => { + await this.syncService.fullSync(false); + + const cipherId = getCipherIdFromParams(params); + if (!cipherId) { + return; + } + const cipherView = new CipherView(); + cipherView.id = cipherId; + if (params.action === "clone") { + await this.cloneCipher(cipherView); + } else if (params.action === "view") { + await this.viewCipher(cipherView); + } else if (params.action === "edit") { + await this.editCipher(cipherView); + } + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { + void this.ngZone.run(async () => { + if (message.command === "syncCompleted" && message.successfully) { + this.refresh(); + } + }); + }); + + this.routedVaultFilterBridgeService.activeFilter$ + .pipe(takeUntil(this.destroy$)) + .subscribe((activeFilter) => { + this.activeFilter = activeFilter; + this.searchBarService.setPlaceholderText( + this.i18nService.t(this.calculateSearchBarLocalizationString(activeFilter)), + ); }); - // Subscribe to filter changes from router params via the bridge service - this.routedVaultFilterBridgeService.activeFilter$ + const filter$ = this.routedVaultFilterService.filter$; + + const allCollections$ = this.collectionService.decryptedCollections$(activeUserId); + const nestedCollections$ = allCollections$.pipe( + map((collections) => getNestedCollectionTree(collections)), + ); + + // Connect search bar to route query params + this.searchBarService.searchText$ .pipe( - switchMap((vaultFilter: VaultFilter) => from(this.applyVaultFilter(vaultFilter))), - takeUntil(this.componentIsDestroyed$), + debounceTime(SearchTextDebounceInterval), + distinctUntilChanged(), + takeUntil(this.destroy$), + ) + .subscribe((searchText: string) => { + void this.router.navigate([], { + queryParams: { search: Utils.isNullOrEmpty(searchText) ? null : searchText }, + queryParamsHandling: "merge", + replaceUrl: true, + state: { + focusMainAfterNav: false, + }, + }); + }); + + const _ciphers = this.cipherService + .cipherListViews$(activeUserId) + .pipe(filter((c) => c !== null)); + + /** + * This observable filters the ciphers based on the active user ID and the restricted item types. + */ + const allowedCiphers$ = combineLatest([ + _ciphers, + this.restrictedItemTypesService.restricted$, + ]).pipe( + map(([ciphers, restrictedTypes]) => + ciphers.filter( + (cipher) => !this.restrictedItemTypesService.isCipherRestricted(cipher, restrictedTypes), + ), + ), + ); + + const ciphers$ = combineLatest([ + allowedCiphers$, + filter$, + this.currentSearchText$, + this.cipherArchiveService.hasArchiveFlagEnabled$, + ]).pipe( + filter(([ciphers, filter]) => ciphers != undefined && filter != undefined), + concatMap(async ([ciphers, filter, searchText, showArchiveVault]) => { + const failedCiphers = + (await firstValueFrom(this.cipherService.failedToDecryptCiphers$(activeUserId))) ?? []; + const filterFunction = createFilterFunction(filter, showArchiveVault); + // Append any failed to decrypt ciphers to the top of the cipher list + const allCiphers = [...failedCiphers, ...ciphers]; + + if (await this.searchService.isSearchable(activeUserId, searchText)) { + return await this.searchService.searchCiphers( + activeUserId, + searchText, + [filterFunction], + allCiphers as C[], + ); + } + + return ciphers.filter(filterFunction) as C[]; + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + const collections$ = combineLatest([nestedCollections$, filter$, this.currentSearchText$]).pipe( + filter(([collections, filter]) => collections != undefined && filter != undefined), + concatMap(async ([collections, filter, searchText]) => { + if (filter.collectionId === undefined || filter.collectionId === Unassigned) { + return []; + } + let searchableCollectionNodes: TreeNode[] = []; + if (filter.organizationId !== undefined && filter.collectionId === All) { + searchableCollectionNodes = collections.filter( + (c) => c.node.organizationId === filter.organizationId, + ); + } else if (filter.collectionId === All) { + searchableCollectionNodes = collections; + } else { + const selectedCollection = ServiceUtils.getTreeNodeObjectFromList( + collections, + filter.collectionId, + ); + searchableCollectionNodes = selectedCollection?.children ?? []; + } + + if (await this.searchService.isSearchable(activeUserId, searchText)) { + // Flatten the tree for searching through all levels + const flatCollectionTree: CollectionView[] = + getFlatCollectionTree(searchableCollectionNodes); + + return this.searchPipe.transform( + flatCollectionTree, + searchText, + (collection) => collection.name, + (collection) => collection.id, + ); + } + + return searchableCollectionNodes.map((treeNode: TreeNode) => treeNode.node); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + const selectedCollection$ = combineLatest([nestedCollections$, filter$]).pipe( + filter(([collections, filter]) => collections != undefined && filter != undefined), + map(([collections, filter]) => { + if ( + filter.collectionId === undefined || + filter.collectionId === All || + filter.collectionId === Unassigned + ) { + return undefined; + } + + return ServiceUtils.getTreeNodeObjectFromList(collections, filter.collectionId); + }), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + + firstSetup$ + .pipe( + switchMap(() => this.route.queryParams), + switchMap(async (params) => { + const cipherId = getCipherIdFromParams(params); + if (!cipherId) { + return; + } + const cipher = await this.cipherService.get(cipherId, activeUserId); + + if (cipher) { + let action = params.action; + // Default to "view" + if (action == null) { + action = "view"; + } + + if (action == "showFailedToDecrypt") { + DecryptionFailureDialogComponent.open(this.dialogService, { + cipherIds: [cipherId as CipherId], + }); + await this.router.navigate([], { + queryParams: { itemId: null, cipherId: null, action: null }, + queryParamsHandling: "merge", + replaceUrl: true, + }); + return; + } + + const cipherView = await cipher + .decrypt(await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId)) + .catch((): any => null); + + if (cipherView) { + if (action === "view") { + await this.viewCipher(cipherView).catch(() => {}); + } else if (action === "clone") { + await this.cloneCipher(cipherView).catch(() => {}); + } else { + await this.editCipher(cipherView).catch(() => {}); + } + } + } else { + await this.handleUnknownCipher(); + } + }), + takeUntil(this.destroy$), ) .subscribe(); - 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 "newSshKey": - await this.addCipher(CipherType.SshKey).catch(() => {}); - break; - case "focusSearch": - (document.querySelector("#search") as HTMLInputElement)?.select(); - detectChanges = false; - break; - case "syncCompleted": - if (this.vaultItemsComponent) { - await this.vaultItemsComponent.refresh().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((): any => 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 authRequests = await firstValueFrom( - this.authRequestService.getLatestPendingAuthRequest$()!, - ); - if (authRequests != null) { - this.messagingService.send("openLoginApproval", { - notificationId: authRequests.id, - }); - } - - this.activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(getUserId), - ).catch((): any => 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), - }); + firstSetup$ + .pipe( + switchMap(() => this.cipherService.failedToDecryptCiphers$(activeUserId)), + filterOutNullish(), + map((ciphers) => ciphers.filter((c) => !c.isDeleted)), + filter((ciphers) => ciphers.length > 0), + take(1), + takeUntil(this.destroy$), + ) + .subscribe((ciphers) => { + DecryptionFailureDialogComponent.open(this.dialogService, { + cipherIds: ciphers.map((c) => c.id as CipherId), }); - } - - this.organizations$.pipe(takeUntil(this.componentIsDestroyed$)).subscribe((orgs) => { - this.allOrganizations = orgs; - }); - - if (!this.activeUserId) { - throw new Error("No user found."); - } - - this.collectionService - .decryptedCollections$(this.activeUserId) - .pipe(takeUntil(this.componentIsDestroyed$)) - .subscribe((collections) => { - this.allCollections = collections; }); - this.vaultFilterService.filteredCollections$ - .pipe(takeUntil(this.componentIsDestroyed$)) - .subscribe((collections) => { - this.filteredCollections = collections; - }); + this.organizations$ + .pipe( + filter((organizations) => organizations.length === 1), + map((organizations) => organizations[0]), + takeUntil(this.destroy$), + ) + .subscribe(); + + firstSetup$ + .pipe( + switchMap(() => this.refresh$), + tap(() => (this.refreshing = true)), + switchMap(() => + combineLatest([ + filter$, + this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId), + allCollections$, + this.organizations$, + ciphers$, + collections$, + selectedCollection$, + ]), + ), + takeUntil(this.destroy$), + ) + .subscribe( + ([ + filter, + canAccessPremium, + allCollections, + allOrganizations, + ciphers, + collections, + selectedCollection, + ]) => { + this.filter = filter; + this.canAccessPremium = canAccessPremium; + this.allCollections = allCollections; + this.allOrganizations = allOrganizations; + this.ciphers = ciphers; + this.collections = collections; + this.selectedCollection = selectedCollection; + this.isEmpty = collections?.length === 0 && ciphers?.length === 0; + this.performingInitialLoad = false; + this.refreshing = false; + + // Explicitly mark for check to ensure the view is updated + // Some sources are not always emitted within the Angular zone (e.g. ciphers updated via WS server notifications) + this.changeDetectorRef.markForCheck(); + }, + ); } ngOnDestroy() { - this.searchBarService.setEnabled(false); this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); - this.componentIsDestroyed$.next(true); - this.componentIsDestroyed$.complete(); + this.destroy$.next(); + this.destroy$.complete(); + this.vaultFilterService.clearOrganizationFilter(); } - async load() { - const params = await firstValueFrom(this.route.queryParams).catch(); - const paramCipherAddType = toCipherType(params.addType); - 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(() => {}); + async onVaultItemsEvent(event: VaultItemEvent) { + this.processingEvent = true; + try { + switch (event.type) { + case "viewAttachments": + await this.openAttachmentsDialog(event.item.id as CipherId); + break; + case "clone": { + const cipher = await this.cipherService.getFullCipherView(event.item); + await this.cloneCipher(cipher); + break; + } + case "restore": + if (event.items.length === 1) { + await this.restoreCipher(event.items[0] as CipherView); + } + break; + case "delete": + await this.handleDeleteEvent(event.items); + break; + case "copyField": + await this.copy(event.item, event.field); + break; + case "assignToCollections": + if (event.items.length === 1) { + const cipher = await this.cipherService.getFullCipherView(event.items[0]); + await this.shareCipher(cipher); + } + break; + case "archive": + if (event.items.length === 1) { + const cipher = await this.cipherService.getFullCipherView(event.items[0]); + if (!cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) { + if (!this.userCanArchive$) { + await this.premiumUpgradePromptService.promptForPremium(); + return; + } + + await this.archiveCipherUtilitiesService.archiveCipher(cipher); + this.refresh(); + } + } + break; + case "unarchive": + if (event.items.length === 1) { + const cipher = await this.cipherService.getFullCipherView(event.items[0]); + await this.archiveCipherUtilitiesService.unarchiveCipher(cipher); + this.refresh(); + } + break; + case "toggleFavorite": + await this.handleFavoriteEvent(event.item); + break; + case "editCipher": { + const fullCipher = await this.cipherService.getFullCipherView(event.item); + await this.editCipher(fullCipher); + break; + } } - } else if (params.action === "add" && paramCipherAddType) { - this.addType = paramCipherAddType; - await this.addCipher(this.addType).catch(() => {}); + } finally { + this.processingEvent = false; } } + async handleUnknownCipher() { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("unknownCipher"), + }); + await this.router.navigate([], { + queryParams: { itemId: null, cipherId: null }, + queryParamsHandling: "merge", + }); + } + /** * Handler for Vault level CopyClickDirectives to send the minimizeOnCopy message */ @@ -419,11 +734,12 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { return; } this.cipherId = cipher.id; - this.cipher = cipher; + this.cipher.set(cipher); this.collections = this.filteredCollections?.filter((c) => cipher.collectionIds.includes(c.id)) ?? null; - this.action = "view"; + this.action.set("view"); + this.changeDetectorRef.detectChanges(); await this.go().catch(() => {}); await this.eventCollectionService.collect( EventType.Cipher_ClientViewed, @@ -437,20 +753,18 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { this.formDisabled = status === "disabled"; } - async openAttachmentsDialog() { - if (!this.userHasPremiumAccess) { + async openAttachmentsDialog(cipherId?: CipherId) { + if (!this.canAccessPremium) { return; } const dialogRef = AttachmentsV2Component.open(this.dialogService, { - cipherId: this.cipherId as CipherId, + cipherId: cipherId ?? (this.cipherId as CipherId), }); const result = await firstValueFrom(dialogRef.closed).catch((): any => null); if ( result?.action === AttachmentDialogResult.Removed || result?.action === AttachmentDialogResult.Uploaded ) { - await this.vaultItemsComponent?.refresh().catch(() => {}); - if (this.cipherFormComponent == null) { return; } @@ -482,169 +796,22 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { } } - async viewCipherMenu(c: CipherViewLike) { - const cipher = await this.cipherService.getFullCipherView(c); - const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - const userCanArchive = await firstValueFrom(this.cipherArchiveService.userCanArchive$(userId)); - const orgOwnershipPolicy = await firstValueFrom( - this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId), - ); - - const menu: RendererMenuItem[] = [ - { - label: this.i18nService.t("view"), - click: () => { - this.functionWithChangeDetection(() => { - this.viewCipher(cipher).catch(() => {}); - }); - }, - }, - ]; - - if (cipher.decryptionFailure) { - invokeMenu(menu); - } - - if (!cipher.isDeleted) { - menu.push({ - label: this.i18nService.t("edit"), - click: () => { - this.functionWithChangeDetection(() => { - this.editCipher(cipher).catch(() => {}); - }); - }, - }); - - const archivedWithOrgOwnership = cipher.isArchived && orgOwnershipPolicy; - const canCloneArchived = !cipher.isArchived || userCanArchive; - - if (!cipher.organizationId && !archivedWithOrgOwnership && canCloneArchived) { - menu.push({ - label: this.i18nService.t("clone"), - click: () => { - this.functionWithChangeDetection(() => { - this.cloneCipher(cipher).catch(() => {}); - }); - }, - }); - } - - const hasEditableCollections = this.allCollections.some((collection) => !collection.readOnly); - - if (cipher.canAssignToCollections && hasEditableCollections) { - menu.push({ - label: this.i18nService.t("assignToCollections"), - click: () => - this.functionWithChangeDetection(async () => { - await this.shareCipher(cipher); - }), - }); - } - } - - if (!cipher.organizationId && !cipher.isDeleted && !cipher.isArchived) { - menu.push({ - label: this.i18nService.t("archiveVerb"), - click: async () => { - if (!userCanArchive) { - await this.premiumUpgradePromptService.promptForPremium(); - return; - } - - await this.archiveCipherUtilitiesService.archiveCipher(cipher); - await this.refreshCurrentCipher(); - }, - }); - } - - if (cipher.isArchived) { - menu.push({ - label: this.i18nService.t("unArchive"), - click: async () => { - await this.archiveCipherUtilitiesService.unarchiveCipher(cipher); - await this.refreshCurrentCipher(); - }, - }); - } - - 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((): any => 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 { return !(await this.canNavigateAway(action, cipher)) || !(await this.passwordReprompt(cipher)); } + protected deleteCipherWithServer(id: string, userId: UserId, permanent: boolean) { + return permanent + ? this.cipherService.deleteWithServer(id, userId) + : this.cipherService.softDeleteWithServer(id, userId); + } + + protected async repromptCipher(ciphers: C[]) { + const notProtected = !ciphers.find((cipher) => cipher.reprompt !== CipherRepromptType.None); + + return notProtected || (await this.passwordRepromptService.showPasswordPrompt()); + } + async buildFormConfig(action: CipherFormMode) { this.config = await this.formConfigService .buildConfig(action, this.cipherId as CipherId, this.addType) @@ -656,12 +823,13 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { return; } this.cipherId = cipher.id; - this.cipher = cipher; + this.cipher.set(cipher); await this.buildFormConfig("edit"); if (!cipher.edit && this.config) { this.config.mode = "partial-edit"; } - this.action = "edit"; + this.action.set("edit"); + this.changeDetectorRef.detectChanges(); await this.go().catch(() => {}); } @@ -670,9 +838,10 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { return; } this.cipherId = cipher.id; - this.cipher = cipher; + this.cipher.set(cipher); await this.buildFormConfig("clone"); - this.action = "clone"; + this.action.set("clone"); + this.changeDetectorRef.detectChanges(); await this.go().catch(() => {}); } @@ -715,15 +884,16 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { } async addCipher(type: CipherType) { - if (this.action === "add") { + if (this.action() === "add") { return; } this.addType = type || this.activeFilter.cipherType; - this.cipher = new CipherView(); + this.cipher.set(new CipherView()); this.cipherId = null; await this.buildFormConfig("add"); - this.action = "add"; + this.action.set("add"); this.prefillCipherFromFilter(); + this.changeDetectorRef.detectChanges(); await this.go().catch(() => {}); if (type === CipherType.SshKey) { @@ -737,8 +907,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { async savedCipher(cipher: CipherView) { this.cipherId = null; - this.action = "view"; - await this.vaultItemsComponent?.refresh().catch(() => {}); + this.action.set("view"); if (!this.activeUserId) { throw new Error("No userId provided."); @@ -751,72 +920,118 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { ); this.cipherId = cipher.id; - this.cipher = cipher; + this.cipher.set(cipher); + this.changeDetectorRef.detectChanges(); await this.go().catch(() => {}); - await this.vaultItemsComponent?.refresh().catch(() => {}); } - async deleteCipher() { + async deleteCipher(c: CipherView): Promise { + if (!(await this.repromptCipher([c as C]))) { + return; + } + + if (!c.edit) { + this.showMissingPermissionsError(); + return; + } + + const permanent = CipherViewLikeUtils.isDeleted(c); + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: permanent ? "permanentlyDeleteItem" : "deleteItem" }, + content: { key: permanent ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + try { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + await this.deleteCipherWithServer(uuidAsString(c.id), activeUserId, permanent); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t(permanent ? "permanentlyDeletedItem" : "deletedItem"), + }); + this.refresh(); + } catch (e) { + this.logService.error(e); + } + this.cipherId = null; - this.cipher = null; - this.action = null; + this.cipher.set(null); + this.action.set(null); + this.changeDetectorRef.detectChanges(); await this.go().catch(() => {}); - await this.vaultItemsComponent?.refresh().catch(() => {}); } - async restoreCipher() { + restoreCipher = async (c: CipherView): Promise => { + if (!CipherViewLikeUtils.isDeleted(c)) { + return; + } + + if (!c.edit) { + this.showMissingPermissionsError(); + return; + } + + if (!(await this.repromptCipher([c as C]))) { + return; + } + + try { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + await this.cipherService.restoreWithServer(uuidAsString(c.id), activeUserId); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("restoredItem"), + }); + this.refresh(); + } catch (e) { + this.logService.error(e); + } + this.cipherId = null; - this.action = null; + this.action.set(null); + this.changeDetectorRef.detectChanges(); await this.go().catch(() => {}); - await this.vaultItemsComponent?.refresh().catch(() => {}); - } + }; - async cancelCipher(cipher: CipherView) { - this.cipherId = cipher.id; - this.cipher = cipher; - this.action = this.cipherId ? "view" : null; + async cancelCipher() { + this.cipherId = null; + this.cipher.set(null); + this.action.set(null); + this.changeDetectorRef.detectChanges(); await this.go().catch(() => {}); } - /** - * Wraps a filter function to handle CipherListView objects. - * CipherListView has a different type structure where type can be a string or object. - * This wrapper converts it to CipherView-compatible structure before filtering. - */ - private wrapFilterForCipherListView( - filterFn: (cipher: CipherView) => boolean, - ): (cipher: CipherViewLike) => boolean { - return (cipher: CipherViewLike) => { - // For CipherListView, create a proxy object with the correct type property - if (CipherViewLikeUtils.isCipherListView(cipher)) { - const proxyCipher = { - ...cipher, - type: CipherViewLikeUtils.getType(cipher), - // Normalize undefined organizationId to null for filter compatibility - organizationId: cipher.organizationId ?? null, - // Explicitly include isDeleted and isArchived since they might be getters - isDeleted: CipherViewLikeUtils.isDeleted(cipher), - isArchived: CipherViewLikeUtils.isArchived(cipher), - }; - return filterFn(proxyCipher as any); - } - }; + async handleFavoriteEvent(cipher: C) { + const cipherFullView = await this.cipherService.getFullCipherView(cipher); + cipherFullView.favorite = !cipherFullView.favorite; + const encryptedCipher = await this.cipherService.encrypt(cipherFullView, this.activeUserId); + await this.cipherService.updateWithServer(encryptedCipher); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t( + cipherFullView.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites", + ), + }); + + this.refresh(); } - async applyVaultFilter(vaultFilter: VaultFilter) { - this.searchBarService.setPlaceholderText( - this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)), - ); - this.activeFilter = vaultFilter; - - const originalFilterFn = this.activeFilter.buildFilter(); - const wrappedFilterFn = this.wrapFilterForCipherListView(originalFilterFn); - - await this.vaultItemsComponent?.reload( - wrappedFilterFn, - vaultFilter.isDeleted, - vaultFilter.isArchived, - ); + private async handleDeleteEvent(items: VaultItem[]) { + const ciphers: C[] = items.filter((i) => i.collection === undefined).map((i) => i.cipher); + const collections = items.filter((i) => i.cipher === undefined).map((i) => i.collection); + if (ciphers.length === 1 && collections.length === 0) { + await this.deleteCipher(ciphers[0] as CipherView); + } } private getAvailableCollections(cipher: CipherView): CollectionView[] { @@ -829,14 +1044,40 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { return this.allCollections.filter((c) => c.organizationId === organization?.id && !c.readOnly); } + private showMissingPermissionsError() { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("missingPermissions"), + }); + } + private calculateSearchBarLocalizationString(vaultFilter: VaultFilter): string { if (vaultFilter.isFavorites) { return "searchFavorites"; } + if (vaultFilter.isArchived) { + return "searchArchive"; + } if (vaultFilter.isDeleted) { return "searchTrash"; } if (vaultFilter.cipherType != null) { + if (vaultFilter.cipherType === CipherType.Login) { + return "searchLogin"; + } + if (vaultFilter.cipherType === CipherType.Card) { + return "searchCard"; + } + if (vaultFilter.cipherType === CipherType.Identity) { + return "searchIdentity"; + } + if (vaultFilter.cipherType === CipherType.SecureNote) { + return "searchSecureNote"; + } + if (vaultFilter.cipherType === CipherType.SshKey) { + return "searchSshKey"; + } return "searchType"; } if (vaultFilter.folderId != null && vaultFilter.folderId !== "none") { @@ -846,7 +1087,11 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { return "searchCollection"; } if (vaultFilter.organizationId != null) { - return "searchOrganization"; + if (vaultFilter.isMyVaultSelected) { + return "searchMyVault"; + } else { + return "searchOrganization"; + } } if (vaultFilter.isMyVaultSelected) { return "searchMyVault"; @@ -871,23 +1116,31 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { } } + /** Trigger a refresh of the vault data */ + private refresh() { + this.refresh$.next(); + } + /** Refresh the current cipher object */ protected async refreshCurrentCipher() { - if (!this.cipher) { + if (!this.cipher()) { return; } - this.cipher = await firstValueFrom( - this.cipherService.cipherViews$(this.activeUserId!).pipe( - filter((c) => !!c), - map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null), + this.cipher.set( + await firstValueFrom( + this.cipherService.cipherViews$(this.activeUserId!).pipe( + filter((c) => !!c), + map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null), + ), ), ); } private dirtyInput(): boolean { + const currentAction = this.action(); return ( - (this.action === "add" || this.action === "edit" || this.action === "clone") && + (currentAction === "add" || currentAction === "edit" || currentAction === "clone") && document.querySelectorAll("vault-cipher-form .ng-dirty").length > 0 ); } @@ -906,8 +1159,8 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { private async go(queryParams: any = null) { if (queryParams == null) { queryParams = { - action: this.action, - cipherId: this.cipherId, + action: this.action(), + itemId: this.cipherId, }; } this.router @@ -920,32 +1173,72 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { .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)), - }); - this.messagingService.send("minimizeOnCopy"); - })().catch(() => {}); - }); - } + async copy(cipher: C, field: "username" | "password" | "totp") { + let aType; + let value; + let typeI18nKey; - private functionWithChangeDetection(func: () => void) { - this.ngZone.run(() => { - func(); - this.changeDetectorRef.detectChanges(); + const login = CipherViewLikeUtils.getLogin(cipher); + + if (!login) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("unexpectedError"), + }); + } + + if (field === "username") { + aType = "Username"; + value = login.username; + typeI18nKey = "username"; + } else if (field === "password") { + aType = "Password"; + value = await this.getPasswordFromCipherViewLike(cipher); + typeI18nKey = "password"; + } else if (field === "totp") { + aType = "TOTP"; + const totpResponse = await firstValueFrom(this.totpService.getCode$(login.totp)); + value = totpResponse.code; + typeI18nKey = "verificationCodeTotp"; + } else { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("unexpectedError"), + }); + return; + } + + if ( + this.passwordRepromptService.protectedFields().includes(aType) && + !(await this.repromptCipher([cipher])) + ) { + return; + } + + if (!cipher.viewPassword) { + return; + } + + this.platformUtilsService.copyToClipboard(value, { window: window }); + this.toastService.showToast({ + variant: "info", + title: null, + message: this.i18nService.t("valueCopied", this.i18nService.t(typeI18nKey)), }); + + if (field === "password") { + await this.eventCollectionService.collect( + EventType.Cipher_ClientCopiedPassword, + uuidAsString(cipher.id), + ); + } else if (field === "totp") { + await this.eventCollectionService.collect( + EventType.Cipher_ClientCopiedHiddenField, + uuidAsString(cipher.id), + ); + } } private prefillCipherFromFilter() { @@ -980,7 +1273,7 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { } private async canNavigateAway(action: string, cipher?: CipherView) { - if (this.action === action && (!cipher || this.cipherId === cipher.id)) { + if (this.action() === action && (!cipher || this.cipherId === cipher.id)) { return false; } else if (this.dirtyInput() && (await this.wantsToSaveChanges())) { return false; @@ -1002,4 +1295,26 @@ export class VaultComponent implements OnInit, OnDestroy, CopyClickListener { } return repromptResult; } + + /** + * Returns the password for a `CipherViewLike` object. + * `CipherListView` does not contain the password, the full `CipherView` needs to be fetched. + */ + private async getPasswordFromCipherViewLike(cipher: C): Promise { + if (!CipherViewLikeUtils.isCipherListView(cipher)) { + return Promise.resolve(cipher.login?.password); + } + + const _cipher = await this.cipherService.get(uuidAsString(cipher.id), this.activeUserId); + const cipherView = await this.cipherService.decrypt(_cipher, this.activeUserId); + return cipherView.login?.password; + } } + +/** + * Allows backwards compatibility with + * old links that used the original `cipherId` param + */ +const getCipherIdFromParams = (params: Params): string => { + return params["itemId"] || params["cipherId"]; +}; diff --git a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts index 8cd4b98af40..a4701de7c6f 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-item-event.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-item-event.ts @@ -1,11 +1,11 @@ import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { VaultItemEvent as BaseVaultItemEvent } from "@bitwarden/vault"; import { CollectionPermission } from "@bitwarden/web-vault/app/admin-console/organizations/shared/components/access-selector"; -import { VaultItem } from "./vault-item"; - +// Extend base events with web-specific events export type VaultItemEvent = - | { type: "viewAttachments"; item: C } + | BaseVaultItemEvent | { type: "bulkEditCollectionAccess"; items: CollectionView[] } | { type: "viewCollectionAccess"; @@ -13,15 +13,4 @@ export type VaultItemEvent = readonly: boolean; initialPermission?: CollectionPermission; } - | { type: "viewEvents"; item: C } - | { type: "editCollection"; item: CollectionView; readonly: boolean } - | { type: "clone"; item: C } - | { type: "restore"; items: C[] } - | { type: "delete"; items: VaultItem[] } - | { type: "copyField"; item: C; field: "username" | "password" | "totp" } - | { type: "moveToFolder"; items: C[] } - | { type: "assignToCollections"; items: C[] } - | { type: "archive"; items: C[] } - | { type: "unarchive"; items: C[] } - | { type: "toggleFavorite"; item: C } - | { type: "editCipher"; item: C }; + | { type: "editCollection"; item: CollectionView; readonly: boolean }; diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts index ac74e75f07c..cdc599a6c3e 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts @@ -11,9 +11,8 @@ import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/res import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { MenuModule, TableModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; -import { RoutedVaultFilterService, RoutedVaultFilterModel } from "@bitwarden/vault"; +import { RoutedVaultFilterService, RoutedVaultFilterModel, VaultItem } from "@bitwarden/vault"; -import { VaultItem } from "./vault-item"; import { VaultItemsComponent } from "./vault-items.component"; describe("VaultItemsComponent", () => { diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index 3511257d6da..0bace616efd 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -31,7 +31,7 @@ import { } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; import { SortDirection, TableDataSource } from "@bitwarden/components"; import { OrganizationId } from "@bitwarden/sdk-internal"; -import { RoutedVaultFilterService } from "@bitwarden/vault"; +import { RoutedVaultFilterService, VaultItem } from "@bitwarden/vault"; import { GroupView } from "../../../admin-console/organizations/core"; @@ -39,7 +39,6 @@ import { CollectionPermission, convertToPermission, } from "./../../../admin-console/organizations/shared/components/access-selector/access-selector.models"; -import { VaultItem } from "./vault-item"; import { VaultItemEvent } from "./vault-item-event"; // Fixed manual row height required due to how cdk-virtual-scroll works diff --git a/apps/web/src/app/vault/individual-vault/pipes/pipes.module.ts b/apps/web/src/app/vault/individual-vault/pipes/pipes.module.ts index 16b95717718..b024b24bab3 100644 --- a/apps/web/src/app/vault/individual-vault/pipes/pipes.module.ts +++ b/apps/web/src/app/vault/individual-vault/pipes/pipes.module.ts @@ -1,7 +1,8 @@ import { NgModule } from "@angular/core"; +import { GetOrgNameFromIdPipe } from "@bitwarden/vault"; + import { GetGroupNameFromIdPipe } from "./get-group-name.pipe"; -import { GetOrgNameFromIdPipe } from "./get-organization-name.pipe"; @NgModule({ declarations: [GetOrgNameFromIdPipe, GetGroupNameFromIdPipe], diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 5ca3a11d5ab..7670600ca01 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -112,6 +112,7 @@ import { OrganizationFilter, VaultItemsTransferService, DefaultVaultItemsTransferService, + VaultItem, } from "@bitwarden/vault"; import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services"; import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; @@ -134,7 +135,6 @@ import { 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 { VaultItemsComponent } from "../components/vault-items/vault-items.component"; import { VaultItemsModule } from "../components/vault-items/vault-items.module"; diff --git a/libs/vault/src/components/vault-item-event.ts b/libs/vault/src/components/vault-item-event.ts new file mode 100644 index 00000000000..24cef06b2eb --- /dev/null +++ b/libs/vault/src/components/vault-item-event.ts @@ -0,0 +1,16 @@ +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { VaultItem } from "@bitwarden/vault"; + +export type VaultItemEvent = + | { type: "viewAttachments"; item: C } + | { type: "viewEvents"; item: C } + | { type: "clone"; item: C } + | { type: "restore"; items: C[] } + | { type: "delete"; items: VaultItem[] } + | { type: "copyField"; item: C; field: "username" | "password" | "totp" } + | { type: "moveToFolder"; items: C[] } + | { type: "assignToCollections"; items: C[] } + | { type: "archive"; items: C[] } + | { type: "unarchive"; items: C[] } + | { type: "toggleFavorite"; item: C } + | { type: "editCipher"; item: C }; diff --git a/apps/web/src/app/vault/components/vault-items/vault-item.ts b/libs/vault/src/components/vault-item.ts similarity index 100% rename from apps/web/src/app/vault/components/vault-items/vault-item.ts rename to libs/vault/src/components/vault-item.ts diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index d06d6f3a95f..7cafaa72ce6 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -12,6 +12,7 @@ export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directi export { OrgIconDirective } from "./components/org-icon.directive"; export { CanDeleteCipherDirective } from "./components/can-delete-cipher.directive"; export { DarkImageSourceDirective } from "./components/dark-image-source.directive"; +export { GetOrgNameFromIdPipe } from "./pipes/get-organization-name.pipe"; export * from "./cipher-view"; export * from "./cipher-form"; @@ -30,6 +31,8 @@ export * from "./components/carousel"; export * from "./components/new-cipher-menu/new-cipher-menu.component"; export * from "./components/permit-cipher-details-popover/permit-cipher-details-popover.component"; export * from "./components/vault-items-transfer"; +export { VaultItem } from "./components/vault-item"; +export { VaultItemEvent } from "./components/vault-item-event"; export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service"; export { SshImportPromptService } from "./services/ssh-import-prompt.service"; diff --git a/apps/web/src/app/vault/individual-vault/pipes/get-organization-name.pipe.ts b/libs/vault/src/pipes/get-organization-name.pipe.ts similarity index 100% rename from apps/web/src/app/vault/individual-vault/pipes/get-organization-name.pipe.ts rename to libs/vault/src/pipes/get-organization-name.pipe.ts