diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index e9b6dfdc9e5..5912ef21cb8 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -54,7 +54,7 @@ import { Fido2CreateComponent } from "../autofill/modal/credentials/fido2-create import { Fido2ExcludedCiphersComponent } from "../autofill/modal/credentials/fido2-excluded-ciphers.component"; import { Fido2VaultComponent } from "../autofill/modal/credentials/fido2-vault.component"; import { VaultV2Component } from "../vault/app/vault/vault-v2.component"; -import { VaultComponent } from "../vault/app/vault-v3/vault.component"; +import { VaultWrapperComponent } from "../vault/app/vault-v3/vault-wrapper.component"; import { DesktopLayoutComponent } from "./layout/desktop-layout.component"; import { SendComponent } from "./tools/send/send.component"; @@ -358,7 +358,8 @@ const routes: Routes = [ children: [ { path: "new-vault", - component: VaultComponent, + component: VaultWrapperComponent, + data: { pageTitle: { key: "vault" } } satisfies RouteDataProperties, }, { path: "new-sends", diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 97a38235fd7..44309929419 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" }, @@ -586,6 +589,12 @@ "editedItem": { "message": "Item saved" }, + "itemAddedToFavorites": { + "message": "Item added to favorites" + }, + "itemRemovedFromFavorites": { + "message": "Item removed from favorites" + }, "deleteItem": { "message": "Delete item" }, @@ -1555,6 +1564,18 @@ "unknown": { "message": "Unknown" }, + "copyAddress": { + "message": "Copy address" + }, + "copyPhone": { + "message": "Copy phone" + }, + "copyNote": { + "message": "Copy note" + }, + "copyVerificationCode": { + "message": "Copy verification code" + }, "copyUsername": { "message": "Copy username" }, @@ -2082,6 +2103,24 @@ "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" }, @@ -2378,6 +2417,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" }, @@ -4086,6 +4128,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": { @@ -4590,6 +4635,36 @@ "whyAmISeeingThis": { "message": "Why am I seeing this?" }, + "organizationIsSuspended": { + "message": "Organization is suspended" + }, + "organizationIsSuspendedDesc": { + "message": "Items in suspended organizations cannot be accessed. Contact your organization owner for assistance." + }, + "noItemsInTrash": { + "message": "No items in trash" + }, + "noItemsInTrashDesc": { + "message": "Items you delete will appear here and be permanently deleted after 30 days" + }, + "noItemsInVault": { + "message": "No items in the vault" + }, + "emptyVaultDescription": { + "message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here." + }, + "emptyFavorites": { + "message": "You haven't favorited any items" + }, + "emptyFavoritesDesc": { + "message": "Add frequently used items to favorites for quick access." + }, + "noSearchResults": { + "message": "No search results returned" + }, + "clearFiltersOrTryAnother": { + "message": "Clear filters or try another search term" + }, "sendPasswordHelperText": { "message": "Individuals will need to enter the password to view this Send", "description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." diff --git a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.ts index 520c29833e3..b5c39414eae 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.ts +++ b/apps/desktop/src/vault/app/vault-v3/vault-filter/filters/organization-filter.component.ts @@ -55,13 +55,6 @@ export class OrganizationFilterComponent { protected applyFilter(event: Event, organization: TreeNode) { event.stopPropagation(); - if (!organization.node.enabled) { - this.toastService.showToast({ - variant: "error", - message: this.i18nService.t("disabledOrganizationFilterError"), - }); - return; - } this.vaultFilterService.setOrganizationFilter(organization.node); const filter = this.activeFilter(); 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..cf4bc6ceebd --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.html @@ -0,0 +1,195 @@ + + +
+ + @if (hasAttachments()) { + + {{ "attachments" | i18n }} + @if (showFixOldAttachments()) { + + {{ "attachmentsNeedFix" | i18n }} + } + } + {{ subtitle() }} +
+ +@if (showOwner()) { + + + + +} + + @if (decryptionFailure()) { + + + @if (canDeleteCipher()) { + + } + + } @else { + @if (canLaunch()) { + + } + @if (showCopyButton()) { + + + @for (copyField of copyFields(); track copyField.field) { + + } + + } + + + + @if (canLaunch()) { + + } + @for (copyField of copyFields(); track copyField.field) { + + } + @if (showMenuDivider()) { + + } + @if (showFavorite()) { + + } + @if (canEditCipher()) { + + } + @if (showAttachments()) { + + } + @if (showClone()) { + + } + @if (showAssignToCollections()) { + + } + @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..98b0db1f05e --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-cipher-row.component.ts @@ -0,0 +1,301 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { NgClass } from "@angular/common"; +import { Component, HostListener, ViewChild, computed, inject, input, output } from "@angular/core"; + +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge/premium-badge.component"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { + AriaDisableDirective, + BitIconButtonComponent, + MenuModule, + MenuTriggerForDirective, + TooltipDirective, + TableModule, +} from "@bitwarden/components"; +import { + CopyAction, + CopyCipherFieldDirective, + GetOrgNameFromIdPipe, + OrganizationNameBadgeComponent, +} from "@bitwarden/vault"; + +import { VaultItemEvent } from "./vault-item-event"; + +/** Configuration for a copyable field */ +interface CopyFieldConfig { + field: CopyAction; + title: string; +} + +// 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", + imports: [ + NgClass, + JslibModule, + TableModule, + AriaDisableDirective, + OrganizationNameBadgeComponent, + TooltipDirective, + BitIconButtonComponent, + MenuModule, + CopyCipherFieldDirective, + PremiumBadgeComponent, + GetOrgNameFromIdPipe, + ], +}) +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 showPremiumFeatures = input(); + protected readonly useEvents = input(); + protected readonly cloneable = input(); + protected readonly organizations = 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 platformUtilsService = inject(PlatformUtilsService); + + protected readonly showArchiveButton = computed(() => { + return ( + this.archiveEnabled() && + !this.cipher().organizationId && + !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(() => { + return ( + CipherViewLikeUtils.isArchived(this.cipher()) && !CipherViewLikeUtils.isDeleted(this.cipher()) + ); + }); + + 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 handleLaunch() { + const launchUri = CipherViewLikeUtils.getLaunchUri(this.cipher()); + this.platformUtilsService.launchUri(launchUri); + } + + 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 showMenuDivider = computed(() => this.showCopyButton() || this.canLaunch()); + + /** + * Returns the list of copyable fields based on cipher type. + * Used to render copy menu items dynamically. + */ + protected readonly copyFields = computed((): CopyFieldConfig[] => { + const cipher = this.cipher(); + + // No copy options for deleted or archived items + if (this.isDeleted() || CipherViewLikeUtils.isArchived(cipher)) { + return []; + } + + const cipherType = CipherViewLikeUtils.getType(cipher); + + switch (cipherType) { + case CipherType.Login: { + const fields: CopyFieldConfig[] = [{ field: "username", title: "copyUsername" }]; + if (cipher.viewPassword) { + fields.push({ field: "password", title: "copyPassword" }); + } + if ( + CipherViewLikeUtils.getLogin(cipher).totp && + (cipher.organizationUseTotp || this.showPremiumFeatures()) + ) { + fields.push({ field: "totp", title: "copyVerificationCode" }); + } + return fields; + } + case CipherType.Card: + return [ + { field: "cardNumber", title: "copyNumber" }, + { field: "securityCode", title: "copySecurityCode" }, + ]; + case CipherType.Identity: + return [ + { field: "username", title: "copyUsername" }, + { field: "email", title: "copyEmail" }, + { field: "phone", title: "copyPhone" }, + { field: "address", title: "copyAddress" }, + ]; + case CipherType.SecureNote: + return [{ field: "secureNote", title: "copyNote" }]; + default: + return []; + } + }); + + /** + * Determines if the copy button should be shown. + * Returns true only if at least one field has a copyable value. + */ + protected readonly showCopyButton = computed(() => { + const cipher = this.cipher(); + return this.copyFields().some(({ field }) => + CipherViewLikeUtils.hasCopyableValue(cipher, field), + ); + }); + + 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() }); + } + + protected viewCipher() { + this.onEvent.emit({ type: "viewCipher", 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..bdcbeb63845 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-collection-row.component.html @@ -0,0 +1,40 @@ + +
+ + +
+ +@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..6d3544f43b5 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-collection-row.component.ts @@ -0,0 +1,38 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { NgClass } from "@angular/common"; +import { Component, input } from "@angular/core"; +import { RouterLink } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + CollectionView, + CollectionTypes, +} from "@bitwarden/common/admin-console/models/collections"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { TableModule } from "@bitwarden/components"; +import { GetOrgNameFromIdPipe, OrganizationNameBadgeComponent } 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[appVaultCollectionRow]", + templateUrl: "vault-collection-row.component.html", + imports: [ + TableModule, + NgClass, + JslibModule, + RouterLink, + OrganizationNameBadgeComponent, + GetOrgNameFromIdPipe, + ], +}) +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-item-event.ts b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-item-event.ts new file mode 100644 index 00000000000..c3a1af43d07 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-items/vault-item-event.ts @@ -0,0 +1,7 @@ +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { VaultItemEvent as BaseVaultItemEvent } from "@bitwarden/vault"; + +// Extend base events with desktop-specific events +export type VaultItemEvent = + | BaseVaultItemEvent + | { type: "viewCipher"; item: C }; 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..8f84ec72a1f --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-list.component.html @@ -0,0 +1,103 @@ +@if (showPremiumCallout()) { +
+ + +
+ {{ "premiumSubscriptionEndedDesc" | i18n }} +
+ + {{ "restartPremium" | i18n }} + +
+
+
+} + +
+ + + + + + {{ "name" | i18n }} + + @if (showOwner()) { + + {{ "owner" | i18n }} + + } + + {{ "options" | i18n }} + + + + + + @if (item.collection) { + + } + @if (item.cipher) { + + } + + + +
+ @if (isEmpty()) { + +
+ {{ emptyStateItem()?.title | i18n }} +
+

+ {{ emptyStateItem()?.description | i18n }} +

+ @if (showAddCipherBtn()) { + + } +
+ } +
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..9767692b7a6 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-list.component.ts @@ -0,0 +1,212 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +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 { BitSvg } from "@bitwarden/assets/svg"; +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.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, + NoItemsModule, + CalloutComponent, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { NewCipherMenuComponent, VaultItem } from "@bitwarden/vault"; + +import { VaultCipherRowComponent } from "./vault-items/vault-cipher-row.component"; +import { VaultCollectionRowComponent } from "./vault-items/vault-collection-row.component"; +import { VaultItemEvent } from "./vault-items/vault-item-event"; + +// Fixed manual row height required due to how cdk-virtual-scroll works +export const RowHeight = 75; +export const RowHeightClass = `tw-h-[75px]`; +type EmptyStateItem = { + title: string; + description: string; + icon: BitSvg; +}; + +// 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, + VaultCollectionRowComponent, + VaultCipherRowComponent, + NoItemsModule, + NewCipherMenuComponent, + CalloutComponent, + ], +}) +export class VaultListComponent { + protected RowHeight = RowHeight; + + protected readonly disabled = input(); + protected readonly showOwner = input(); + protected readonly showPremiumFeatures = input(); + protected readonly allOrganizations = input([]); + protected readonly allCollections = input([]); + protected readonly userCanArchive = input(); + protected readonly enforceOrgDataOwnershipPolicy = input(); + protected readonly placeholderText = input(""); + protected readonly ciphers = input([]); + protected readonly collections = input([]); + protected readonly isEmpty = input(); + protected readonly showAddCipherBtn = input(); + protected readonly emptyStateItem = input(); + readonly showPremiumCallout = input(false); + + protected onEvent = output>(); + protected onAddCipher = output(); + protected onAddFolder = output(); + + protected cipherAuthorizationService = inject(CipherAuthorizationService); + protected restrictedItemTypesService = inject(RestrictedItemTypesService); + protected cipherArchiveService = inject(CipherArchiveService); + private premiumUpgradePromptService = inject(PremiumUpgradePromptService); + + protected dataSource = new TableDataSource>(); + private restrictedTypes: RestrictedCipherType[] = []; + + protected archiveFeatureEnabled$ = this.cipherArchiveService.hasArchiveFlagEnabled$; + + constructor() { + 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); + }), + ); + } + + protected canEditCipher(cipher: C) { + if (cipher.organizationId == null) { + return true; + } + return cipher.edit; + } + + protected canAssignCollections(cipher: C) { + const editableCollections = this.allCollections().filter((c) => !c.readOnly); + return 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; + } + + 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().map((cipher) => ({ cipher })); + const items: VaultItem[] = [].concat(collections).concat(ciphers); + + this.dataSource.data = items; + } + + /** + * 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; + } + + protected trackByFn(index: number, item: VaultItem) { + return item.cipher?.id || item.collection?.id || index; + } + + async navigateToGetPremium() { + await this.premiumUpgradePromptService.promptForPremium(); + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-orig.component.html b/apps/desktop/src/vault/app/vault-v3/vault-orig.component.html new file mode 100644 index 00000000000..5d8c3491710 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-orig.component.html @@ -0,0 +1,86 @@ +
+ + + @if (!!action) { +
+ +
+
+
+ @if (action === "view") { + + } + @if (action === "add" || action === "edit" || action === "clone") { + + + + + + } +
+
+
+
+ } + @if (!["add", "edit", "view", "clone"].includes(action)) { + + } +
+ diff --git a/apps/desktop/src/vault/app/vault-v3/vault-orig.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-orig.component.ts new file mode 100644 index 00000000000..b6bab6716fb --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-orig.component.ts @@ -0,0 +1,1043 @@ +// 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, + computed, + NgZone, + OnDestroy, + OnInit, + signal, + ViewChild, +} from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { + combineLatest, + firstValueFrom, + Subject, + takeUntil, + switchMap, + lastValueFrom, + Observable, + from, +} from "rxjs"; +import { filter, map, take } from "rxjs/operators"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; +import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service"; +import { ItemTypes } from "@bitwarden/assets/svg"; +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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { EventType } from "@bitwarden/common/enums"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { getByIds } from "@bitwarden/common/platform/misc"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { CipherId, CollectionId, OrganizationId, 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 { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; +import { CipherType, toCipherType } from "@bitwarden/common/vault/enums"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + CipherViewLike, + CipherViewLikeUtils, +} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; +import { + BadgeModule, + ButtonModule, + DialogService, + ItemModule, + ToastService, + CopyClickListener, + COPY_CLICK_LISTENER, + NoItemsModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { + AttachmentDialogResult, + AttachmentsV2Component, + ChangeLoginPasswordService, + CipherFormConfig, + CipherFormConfigService, + CipherFormGenerationService, + CipherFormMode, + CipherFormModule, + CipherViewComponent, + CollectionAssignmentResult, + createFilterFunction, + DecryptionFailureDialogComponent, + DefaultChangeLoginPasswordService, + DefaultCipherFormConfigService, + PasswordRepromptService, + CipherFormComponent, + ArchiveCipherUtilitiesService, + VaultFilter, + VaultFilterServiceAbstraction as VaultFilterService, + RoutedVaultFilterBridgeService, + RoutedVaultFilterService, + VaultItemsTransferService, + DefaultVaultItemsTransferService, +} from "@bitwarden/vault"; + +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"; + +const BroadcasterSubscriptionId = "VaultComponent"; + +// 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-v3", + templateUrl: "vault-orig.component.html", + imports: [ + BadgeModule, + CommonModule, + CipherFormModule, + CipherViewComponent, + ItemFooterComponent, + I18nPipe, + ItemModule, + ButtonModule, + PremiumBadgeComponent, + VaultItemsV2Component, + NoItemsModule, + ], + providers: [ + { + provide: CipherFormConfigService, + useClass: DefaultCipherFormConfigService, + }, + { + provide: ChangeLoginPasswordService, + useClass: DefaultChangeLoginPasswordService, + }, + { + provide: ViewPasswordHistoryService, + useClass: VaultViewPasswordHistoryService, + }, + { + provide: PremiumUpgradePromptService, + useClass: DesktopPremiumUpgradePromptService, + }, + { provide: CipherFormGenerationService, useClass: DesktopCredentialGenerationService }, + { + provide: COPY_CLICK_LISTENER, + useExisting: VaultComponent, + }, + { provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService }, + ], +}) +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; + // 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: OrganizationId | null = null; + addType: CipherType | undefined = undefined; + addOrganizationId: string | null = null; + addCollectionIds: string[] | null = null; + showingModal = false; + deleted = false; + activeFilter: VaultFilter = new VaultFilter(); + activeUserId: UserId | null = null; + cipherRepromptId: string | null = null; + readonly cipher = signal(null); + collections: CollectionView[] | null = null; + config: CipherFormConfig | null = null; + private userId$ = this.accountService.activeAccount$.pipe(getUserId); + showPremiumCallout$: Observable = this.userId$.pipe( + switchMap((userId) => + combineLatest([ + this.routedVaultFilterBridgeService.activeFilter$, + this.cipherArchiveService.showSubscriptionEndedMessaging$(userId), + ]).pipe(map(([activeFilter, showMessaging]) => activeFilter.isArchived && showMessaging)), + ), + ); + + /** Tracks the disabled status of the edit cipher form */ + protected formDisabled: boolean = false; + + readonly userHasPremium = toSignal( + this.accountService.activeAccount$.pipe( + filter((account): account is Account => !!account), + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ), + ), + { initialValue: false }, + ); + readonly archiveFlagEnabled = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$, { + initialValue: false, + }); + protected itemTypesIcon = ItemTypes; + + private organizations$: Observable = this.accountService.activeAccount$.pipe( + map((a) => a?.id), + filterOutNullish(), + switchMap((id) => this.organizationService.organizations$(id)), + ); + + protected readonly submitButtonText = computed(() => { + return this.cipher()?.isArchived && + !this.userHasPremium() && + this.cipherArchiveService.hasArchiveFlagEnabled$ + ? this.i18nService.t("unArchiveAndSave") + : this.i18nService.t("save"); + }); + + protected hasArchivedCiphers$ = this.userId$.pipe( + switchMap((userId) => + this.cipherArchiveService.archivedCiphers$(userId).pipe(map((ciphers) => ciphers.length > 0)), + ), + ); + + private componentIsDestroyed$ = new Subject(); + private allOrganizations: Organization[] = []; + private allCollections: CollectionView[] = []; + private filteredCollections: CollectionView[] = []; + + 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 routedVaultFilterService: RoutedVaultFilterService, + private vaultFilterService: VaultFilterService, + private vaultItemTransferService: VaultItemsTransferService, + ) {} + + async ngOnInit() { + // Subscribe to filter changes from router params via the bridge service + // Use combineLatest to react to changes in both the filter and archive flag + combineLatest([ + this.routedVaultFilterBridgeService.activeFilter$, + this.routedVaultFilterService.filter$, + this.cipherArchiveService.hasArchiveFlagEnabled$, + ]) + .pipe( + switchMap(([vaultFilter, routedFilter, archiveEnabled]) => + from(this.applyVaultFilter(vaultFilter, routedFilter, archiveEnabled)), + ), + takeUntil(this.componentIsDestroyed$), + ) + .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(() => {}); + } + if (this.activeUserId) { + void this.vaultItemTransferService.enforceOrganizationDataOwnership( + this.activeUserId, + ); + } + 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.userHasPremium()) + ) { + 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), + }); + }); + } + + 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; + }); + + void this.vaultItemTransferService.enforceOrganizationDataOwnership(this.activeUserId); + } + + ngOnDestroy() { + this.searchBarService.setEnabled(false); + this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + this.componentIsDestroyed$.next(true); + this.componentIsDestroyed$.complete(); + } + + async load() { + const params = await firstValueFrom(this.route.queryParams).catch(); + 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(() => {}); + } + } else if (params.action === "add" && paramCipherAddType) { + this.addType = paramCipherAddType; + await this.addCipher(this.addType).catch(() => {}); + } + } + + /** + * Handler for Vault level CopyClickDirectives to send the minimizeOnCopy message + */ + onCopy() { + this.messagingService.send("minimizeOnCopy"); + } + + async viewCipher(c: CipherViewLike) { + if (CipherViewLikeUtils.decryptionFailure(c)) { + DecryptionFailureDialogComponent.open(this.dialogService, { + cipherIds: [c.id as CipherId], + }); + return; + } + const cipher = await this.cipherService.getFullCipherView(c); + if (await this.shouldReprompt(cipher, "view")) { + return; + } + this.cipherId = cipher.id; + this.cipher.set(cipher); + this.collections = + this.filteredCollections?.filter((c) => cipher.collectionIds.includes(c.id)) ?? null; + this.action = "view"; + + await this.go().catch(() => {}); + await this.eventCollectionService.collect( + EventType.Cipher_ClientViewed, + cipher.id, + false, + cipher.organizationId, + ); + } + + formStatusChanged(status: "disabled" | "enabled") { + this.formDisabled = status === "disabled"; + } + + async openAttachmentsDialog() { + if (!this.userHasPremium()) { + return; + } + const dialogRef = AttachmentsV2Component.open(this.dialogService, { + cipherId: this.cipherId as CipherId, + canEditCipher: this.cipher().edit, + }); + 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; + } + + // The encrypted state of ciphers is updated when an attachment is added, + // but the cache is also cleared. Depending on timing, `cipherService.get` can return the + // old cipher. Retrieve the updated cipher from `cipherViews$`, + // which refreshes after the cached is cleared. + const updatedCipherView = await firstValueFrom( + this.cipherService.cipherViews$(this.activeUserId!).pipe( + filter((c) => !!c), + map((ciphers) => ciphers.find((c) => c.id === this.cipherId)), + ), + ); + + // `find` can return undefined but that shouldn't happen as + // this would mean that the cipher was deleted. + // To make TypeScript happy, exit early if it isn't found. + if (!updatedCipherView) { + return; + } + + this.cipherFormComponent.patchCipher((currentCipher) => { + currentCipher.attachments = updatedCipherView.attachments; + currentCipher.revisionDate = updatedCipherView.revisionDate; + + return currentCipher; + }); + } + } + + 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 (this.archiveFlagEnabled() && !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 && !cipher.isDeleted) { + 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.userHasPremium())) { + 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)); + } + + async buildFormConfig(action: CipherFormMode) { + this.config = await this.formConfigService + .buildConfig(action, this.cipherId as CipherId, this.addType) + .catch((): any => null); + } + + async editCipher(cipher: CipherView) { + if (await this.shouldReprompt(cipher, "edit")) { + return; + } + this.cipherId = cipher.id; + this.cipher.set(cipher); + await this.buildFormConfig("edit"); + if (!cipher.edit && this.config) { + this.config.mode = "partial-edit"; + } + this.action = "edit"; + await this.go().catch(() => {}); + } + + async cloneCipher(cipher: CipherView) { + if (await this.shouldReprompt(cipher, "clone")) { + return; + } + this.cipherId = cipher.id; + this.cipher.set(cipher); + await this.buildFormConfig("clone"); + this.action = "clone"; + await this.go().catch(() => {}); + } + + async shareCipher(cipher: CipherView) { + if (!cipher) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("nothingSelected"), + }); + return; + } + + if (!(await this.passwordReprompt(cipher))) { + return; + } + + const availableCollections = this.getAvailableCollections(cipher); + + const dialog = AssignCollectionsDesktopComponent.open(this.dialogService, { + data: { + ciphers: [cipher], + organizationId: cipher.organizationId as OrganizationId, + availableCollections, + }, + }); + + const result = await lastValueFrom(dialog.closed); + if (result === CollectionAssignmentResult.Saved) { + const updatedCipher = await firstValueFrom( + // Fetch the updated cipher from the service + this.cipherService.cipherViews$(this.activeUserId as UserId).pipe( + filter((ciphers) => ciphers != null), + map((ciphers) => ciphers!.find((c) => c.id === cipher.id)), + filter((foundCipher) => foundCipher != null), + ), + ); + await this.savedCipher(updatedCipher); + } + } + + async addCipher(type: CipherType) { + if (this.action === "add") { + return; + } + this.addType = type || this.activeFilter.cipherType; + this.cipher.set(new CipherView()); + this.cipherId = null; + await this.buildFormConfig("add"); + this.action = "add"; + this.prefillCipherFromFilter(); + await this.go().catch(() => {}); + + if (type === CipherType.SshKey) { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("sshKeyGenerated"), + }); + } + } + + async savedCipher(cipher: CipherView) { + this.cipherId = null; + this.action = "view"; + await this.vaultItemsComponent?.refresh().catch(() => {}); + + if (!this.activeUserId) { + throw new Error("No userId provided."); + } + + this.collections = await firstValueFrom( + this.collectionService + .decryptedCollections$(this.activeUserId) + .pipe(getByIds(cipher.collectionIds)), + ); + + this.cipherId = cipher.id; + this.cipher.set(cipher); + await this.go().catch(() => {}); + await this.vaultItemsComponent?.refresh().catch(() => {}); + } + + async deleteCipher() { + this.cipherId = null; + this.cipher.set(null); + this.action = null; + await this.go().catch(() => {}); + await this.vaultItemsComponent?.refresh().catch(() => {}); + } + + async restoreCipher() { + this.cipherId = null; + this.action = null; + await this.go().catch(() => {}); + await this.vaultItemsComponent?.refresh().catch(() => {}); + } + + async cancelCipher(cipher: CipherView) { + this.cipherId = cipher.id; + this.cipher.set(cipher); + this.action = this.cipherId ? "view" : null; + await this.go().catch(() => {}); + } + + async applyVaultFilter( + vaultFilter: VaultFilter, + routedFilter: Parameters[0], + archiveEnabled: boolean, + ) { + this.searchBarService.setPlaceholderText( + this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)), + ); + this.activeFilter = vaultFilter; + + const filterFn = createFilterFunction(routedFilter, archiveEnabled); + + await this.vaultItemsComponent?.reload(filterFn, vaultFilter.isDeleted, vaultFilter.isArchived); + } + + private getAvailableCollections(cipher: CipherView): CollectionView[] { + const orgId = cipher.organizationId; + if (!orgId || orgId === "MyVault") { + return []; + } + + const organization = this.allOrganizations.find((o) => o.id === orgId); + return this.allCollections.filter((c) => c.organizationId === organization?.id && !c.readOnly); + } + + private calculateSearchBarLocalizationString(vaultFilter: VaultFilter): string { + if (vaultFilter.isFavorites) { + return "searchFavorites"; + } + if (vaultFilter.isDeleted) { + return "searchTrash"; + } + if (vaultFilter.cipherType != null) { + return "searchType"; + } + if (vaultFilter.folderId != null && vaultFilter.folderId !== "none") { + return "searchFolder"; + } + if (vaultFilter.collectionId != null) { + return "searchCollection"; + } + if (vaultFilter.organizationId != null) { + return "searchOrganization"; + } + if (vaultFilter.isMyVaultSelected) { + return "searchMyVault"; + } + return "searchVault"; + } + + async addFolder() { + this.messagingService.send("newFolder"); + } + + async editFolder(folderId: string) { + if (!this.activeUserId) { + return; + } + const folderView = await firstValueFrom( + this.folderService.getDecrypted$(folderId, this.activeUserId), + ); + + if (!folderView) { + return; + } + } + + /** Refresh the current cipher object */ + protected async refreshCurrentCipher() { + if (!this.cipher()) { + return; + } + + 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 { + return ( + (this.action === "add" || this.action === "edit" || this.action === "clone") && + document.querySelectorAll("vault-cipher-form .ng-dirty").length > 0 + ); + } + + private async wantsToSaveChanges(): Promise { + const confirmed = await this.dialogService + .openSimpleDialog({ + title: { key: "unsavedChangesTitle" }, + content: { key: "unsavedChangesConfirmation" }, + type: "warning", + }) + .catch(() => false); + return !confirmed; + } + + private async go(queryParams: any = null) { + if (queryParams == null) { + queryParams = { + action: this.action, + cipherId: this.cipherId, + }; + } + this.router + .navigate([], { + relativeTo: this.route, + queryParams: queryParams, + queryParamsHandling: "merge", + replaceUrl: true, + }) + .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(() => {}); + }); + } + + private functionWithChangeDetection(func: () => void) { + this.ngZone.run(() => { + func(); + this.changeDetectorRef.detectChanges(); + }); + } + + private prefillCipherFromFilter() { + if (this.activeFilter.collectionId != null) { + const collections = this.filteredCollections?.filter( + (c) => c.id === this.activeFilter.collectionId, + ); + if (collections?.length > 0) { + this.addOrganizationId = collections[0].organizationId; + this.addCollectionIds = [this.activeFilter.collectionId]; + } + } else if (this.activeFilter.organizationId && this.activeFilter.organizationId !== "MyVault") { + this.addOrganizationId = this.activeFilter.organizationId; + } else { + // clear out organizationId when the user switches to a personal vault filter + this.addOrganizationId = null; + } + if (this.activeFilter.folderId && this.activeFilter.selectedFolderNode) { + this.folderId = this.activeFilter.folderId; + } + + if (this.config == null) { + return; + } + + this.config.initialValues = { + ...this.config.initialValues, + folderId: this.folderId, + organizationId: this.addOrganizationId as OrganizationId, + collectionIds: this.addCollectionIds as CollectionId[], + }; + } + + private async canNavigateAway(action: string, cipher?: CipherView) { + if (this.action === action && (!cipher || this.cipherId === cipher.id)) { + return false; + } else if (this.dirtyInput() && (await this.wantsToSaveChanges())) { + return false; + } + return true; + } + + private async passwordReprompt(cipher: CipherView) { + if (cipher.reprompt === CipherRepromptType.None) { + this.cipherRepromptId = null; + return true; + } + if (this.cipherRepromptId === cipher.id) { + return true; + } + const repromptResult = await this.passwordRepromptService.showPasswordPrompt(); + if (repromptResult) { + this.cipherRepromptId = cipher.id; + } + return repromptResult; + } +} diff --git a/apps/desktop/src/vault/app/vault-v3/vault-wrapper.component.ts b/apps/desktop/src/vault/app/vault-v3/vault-wrapper.component.ts new file mode 100644 index 00000000000..7a5de8cb9c8 --- /dev/null +++ b/apps/desktop/src/vault/app/vault-v3/vault-wrapper.component.ts @@ -0,0 +1,28 @@ +import { CommonModule } from "@angular/common"; +import { Component, computed, inject } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +import { VaultComponent as VaultOrigComponent } from "./vault-orig.component"; +import { VaultComponent } from "./vault.component"; + +// 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-wrapper", + template: '', + imports: [CommonModule], +}) +export class VaultWrapperComponent { + private configService: ConfigService = inject(ConfigService); + + protected readonly useMilestone3 = toSignal( + this.configService.getFeatureFlag$(FeatureFlag.DesktopUiMigrationMilestone3), + ); + + protected readonly componentToRender = computed(() => + this.useMilestone3() ? VaultComponent : VaultOrigComponent, + ); +} 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 5d8c3491710..4a0b37f2d80 100644 --- a/apps/desktop/src/vault/app/vault-v3/vault.component.html +++ b/apps/desktop/src/vault/app/vault-v3/vault.component.html @@ -1,38 +1,53 @@ -
- - - @if (!!action) { +
+
+
+ + + +
+ + +
+ @if (!!action()) {
-
- @if (action === "view") { - + @if (action() === "view") { + } - @if (action === "add" || action === "edit" || action === "clone") { + @if (action() === "add" || action() === "edit" || action() === "clone") {
{{ "attachments" | i18n }} - +
@@ -60,9 +75,23 @@
+
} - @if (!["add", "edit", "view", "clone"].includes(action)) { + @if (!["add", "edit", "view", "clone"].includes(action())) {