diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 64dd8d236ed..97841f2d451 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1483,6 +1483,15 @@ } } }, + "viewItemHeader": { + "message": "View $TYPE$", + "placeholders": { + "type": { + "content": "$1", + "example": "Login" + } + } + }, "passwordHistory": { "message": "Password history" }, @@ -3535,6 +3544,30 @@ "contactYourOrgAdmin": { "message": "Items in deactivated organizations cannot be accessed. Contact your organization owner for assistance." }, + "itemDetails": { + "message": "Item details" + }, + "itemName": { + "message": "Item name" + }, + "additionalInformation": { + "message": "Additional information" + }, + "itemHistory": { + "message": "Item history" + }, + "lastEdited": { + "message": "Last edited" + }, + "ownerYou":{ + "message": "Owner: You" + }, + "linked": { + "message": "Linked" + }, + "copySuccessful": { + "message": "Copy Successful" + }, "upload": { "message": "Upload" }, @@ -3574,6 +3607,15 @@ "filters": { "message": "Filters" }, + "downloadAttachment": { + "message": "Download - $ITEMNAME$", + "placeholders": { + "itemname": { + "content": "$1", + "example": "Your File" + } + } + }, "cardDetails": { "message": "Card details" }, diff --git a/apps/browser/src/platform/popup/layout/popup-footer.component.html b/apps/browser/src/platform/popup/layout/popup-footer.component.html index 2cbbca79c0b..777e0ab60da 100644 --- a/apps/browser/src/platform/popup/layout/popup-footer.component.html +++ b/apps/browser/src/platform/popup/layout/popup-footer.component.html @@ -1,9 +1,12 @@ diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts index cc7758d9680..9883a5cfb6f 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -266,6 +266,7 @@ class MockSettingsPageComponent {} + `, @@ -279,6 +280,7 @@ class MockSettingsPageComponent {} MockPopoutButtonComponent, MockCurrentAccountComponent, VaultComponent, + IconButtonModule, ], }) class MockVaultSubpageComponent {} diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index ff8dc7eeb6d..8645cb797bd 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -71,6 +71,7 @@ import { VaultV2Component } from "../vault/popup/components/vault/vault-v2.compo import { ViewComponent } from "../vault/popup/components/vault/view.component"; import { AddEditV2Component } from "../vault/popup/components/vault-v2/add-edit/add-edit-v2.component"; import { AttachmentsV2Component } from "../vault/popup/components/vault-v2/attachments/attachments-v2.component"; +import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component"; import { AppearanceComponent } from "../vault/popup/settings/appearance.component"; import { FolderAddEditComponent } from "../vault/popup/settings/folder-add-edit.component"; import { FoldersComponent } from "../vault/popup/settings/folders.component"; @@ -211,12 +212,11 @@ const routes: Routes = [ canActivate: [AuthGuard], data: { state: "ciphers" }, }, - { + ...extensionRefreshSwap(ViewComponent, ViewV2Component, { path: "view-cipher", - component: ViewComponent, canActivate: [AuthGuard], data: { state: "view-cipher" }, - }, + }), { path: "cipher-password-history", component: PasswordHistoryComponent, diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index 957747180eb..fbe1d60b448 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -21,7 +21,7 @@ diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html new file mode 100644 index 00000000000..c2516fe05f7 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.html @@ -0,0 +1,20 @@ + + + + + + + + {{ "edit" | i18n }} + + + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts new file mode 100644 index 00000000000..4fe88da5550 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/view-v2/view-v2.component.ts @@ -0,0 +1,157 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormsModule } from "@angular/forms"; +import { ActivatedRoute, Router } from "@angular/router"; +import { Observable, switchMap } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { + AsyncActionsModule, + SearchModule, + ButtonModule, + IconButtonModule, + DialogService, + ToastService, +} from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view"; + +import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "./../../../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "./../../../../../platform/popup/layout/popup-page.component"; + +@Component({ + selector: "app-view-v2", + templateUrl: "view-v2.component.html", + standalone: true, + imports: [ + CommonModule, + SearchModule, + JslibModule, + FormsModule, + ButtonModule, + PopupPageComponent, + PopupHeaderComponent, + PopupFooterComponent, + IconButtonModule, + CipherViewComponent, + AsyncActionsModule, + ], +}) +export class ViewV2Component { + headerText: string; + cipherId: string; + cipher: CipherView; + organization$: Observable; + folder$: Observable; + collections$: Observable; + private passwordReprompted = false; + + constructor( + private route: ActivatedRoute, + private router: Router, + private i18nService: I18nService, + private cipherService: CipherService, + private passwordRepromptService: PasswordRepromptService, + private dialogService: DialogService, + private logService: LogService, + private toastService: ToastService, + ) { + this.subscribeToParams(); + } + + subscribeToParams(): void { + this.route.queryParams + .pipe( + switchMap((param) => { + return this.getCipherData(param.cipherId); + }), + takeUntilDestroyed(), + ) + .subscribe((data) => { + this.cipher = data; + this.headerText = this.setHeader(data.type); + }); + } + + setHeader(type: CipherType) { + switch (type) { + case CipherType.Login: + return this.i18nService.t("viewItemHeader", this.i18nService.t("typeLogin")); + case CipherType.Card: + return this.i18nService.t("viewItemHeader", this.i18nService.t("typeCard")); + case CipherType.Identity: + return this.i18nService.t("viewItemHeader", this.i18nService.t("typeIdentity")); + case CipherType.SecureNote: + return this.i18nService.t("viewItemHeader", this.i18nService.t("note")); + } + } + + async getCipherData(id: string) { + const cipher = await this.cipherService.get(id); + return await cipher.decrypt(await this.cipherService.getKeyForCipherKeyDecryption(cipher)); + } + + editCipher() { + if (this.cipher.isDeleted) { + return false; + } + void this.router.navigate(["/edit-cipher"], { + queryParams: { cipherId: this.cipher.id, type: this.cipher.type, isNew: false }, + }); + return true; + } + + delete = async (): Promise => { + this.passwordReprompted = + this.passwordReprompted || + (await this.passwordRepromptService.passwordRepromptCheck(this.cipher)); + if (!this.passwordReprompted) { + return; + } + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { + key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation", + }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + try { + await this.deleteCipher(); + } catch (e) { + this.logService.error(e); + return false; + } + + await this.router.navigate(["/vault"]); + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t(this.cipher.isDeleted ? "permanentlyDeletedItem" : "deletedItem"), + }); + + return true; + }; + + protected deleteCipher() { + return this.cipher.isDeleted + ? this.cipherService.deleteWithServer(this.cipher.id) + : this.cipherService.softDeleteWithServer(this.cipher.id); + } +} diff --git a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts index abb810c04d5..df78806edf2 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts @@ -198,7 +198,9 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn super.selectCipher(cipher); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/view-cipher"], { queryParams: { cipherId: cipher.id } }); + this.router.navigate(["/view-cipher"], { + queryParams: { cipherId: cipher.id }, + }); } this.preventSelected = false; }, 200); diff --git a/apps/web/src/app/auth/lock.component.html b/apps/web/src/app/auth/lock.component.html index 2cdc4d2a651..f630906223b 100644 --- a/apps/web/src/app/auth/lock.component.html +++ b/apps/web/src/app/auth/lock.component.html @@ -1,66 +1,27 @@ -
-
-
-

- -

-

{{ "yourVaultIsLocked" | i18n }}

-
-
-
- -
- - -
- - {{ "loggedInAsEmailOn" | i18n: email : webVaultHostname }} - -
-
-
- - -
-
-
-
+ + + {{ "masterPass" | i18n }} + + + {{ "loggedInAsEmailOn" | i18n: email : webVaultHostname }} + + +
+ +
+ +
diff --git a/apps/web/src/app/auth/lock.component.ts b/apps/web/src/app/auth/lock.component.ts index 021bf0f9df4..6b52d0160b8 100644 --- a/apps/web/src/app/auth/lock.component.ts +++ b/apps/web/src/app/auth/lock.component.ts @@ -1,18 +1,49 @@ -import { Component } from "@angular/core"; +import { Component, inject } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; import { LockComponent as BaseLockComponent } from "@bitwarden/angular/auth/components/lock.component"; +import { SharedModule } from "../shared"; + @Component({ selector: "app-lock", templateUrl: "lock.component.html", + standalone: true, + imports: [SharedModule], }) export class LockComponent extends BaseLockComponent { + formBuilder = inject(FormBuilder); + + formGroup = this.formBuilder.group({ + masterPassword: ["", { validators: Validators.required, updateOn: "submit" }], + }); + + get masterPasswordFormControl() { + return this.formGroup.controls.masterPassword; + } + async ngOnInit() { await super.ngOnInit(); + + this.masterPasswordFormControl.setValue(this.masterPassword); + this.onSuccessfulSubmit = async () => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigateByUrl(this.successRoute); + await this.router.navigateByUrl(this.successRoute); }; } + + async superSubmit() { + await super.submit(); + } + + submit = async () => { + this.formGroup.markAllAsTouched(); + + if (this.formGroup.invalid) { + return; + } + + this.masterPassword = this.masterPasswordFormControl.value; + await this.superSubmit(); + }; } diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 246f4bdc403..8f3638ac2e9 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -17,6 +17,7 @@ import { RegistrationStartComponent, RegistrationStartSecondaryComponent, RegistrationStartSecondaryComponentData, + LockIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -113,11 +114,6 @@ const routes: Routes = [ component: SetPasswordComponent, data: { titleId: "setMasterPassword" } satisfies DataProperties, }, - { - path: "lock", - component: LockComponent, - canActivate: [deepLinkGuard(), lockGuard()], - }, { path: "verify-email", component: VerifyEmailTokenComponent }, { path: "accept-organization", @@ -246,6 +242,21 @@ const routes: Routes = [ pageTitle: "logIn", }, }, + { + path: "lock", + canActivate: [deepLinkGuard(), lockGuard()], + children: [ + { + path: "", + component: LockComponent, + }, + ], + data: { + pageTitle: "yourVaultIsLockedV2", + pageIcon: LockIcon, + showReadonlyHostname: true, + } satisfies AnonLayoutWrapperData, + }, { path: "2fa", canActivate: [unauthGuardFn()], diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index 107cdd040e4..9f67c859f2c 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -22,7 +22,6 @@ import { VerifyRecoverDeleteProviderComponent } from "../admin-console/providers import { SponsoredFamiliesComponent } from "../admin-console/settings/sponsored-families.component"; import { SponsoringOrgRowComponent } from "../admin-console/settings/sponsoring-org-row.component"; import { HintComponent } from "../auth/hint.component"; -import { LockComponent } from "../auth/lock.component"; import { RecoverDeleteComponent } from "../auth/recover-delete.component"; import { RecoverTwoFactorComponent } from "../auth/recover-two-factor.component"; import { RegisterFormModule } from "../auth/register-form/register-form.module"; @@ -141,7 +140,6 @@ import { SharedModule } from "./shared.module"; FolderAddEditComponent, FrontendLayoutComponent, HintComponent, - LockComponent, OrgAddEditComponent, OrgAttachmentsComponent, OrgCollectionsComponent, @@ -213,7 +211,6 @@ import { SharedModule } from "./shared.module"; FolderAddEditComponent, FrontendLayoutComponent, HintComponent, - LockComponent, OrgAddEditComponent, OrganizationLayoutComponent, OrgAttachmentsComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 66a768a0fa3..16df4420246 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -854,8 +854,8 @@ "emailAddress": { "message": "Email address" }, - "yourVaultIsLocked": { - "message": "Your vault is locked. Verify your master password to continue." + "yourVaultIsLockedV2": { + "message": "Your vault is locked." }, "uuid":{ "message" : "UUID" @@ -8541,5 +8541,8 @@ "contactBitwardenSupport": { "message": "contact Bitwarden support.", "description": "This will be displayed as part of a larger sentence. The whole sentence reads: 'Notice: Later this month, client vault privacy will be improved and provider members will no longer have direct access to client vault items. For questions, please contact Bitwarden support'. 'Bitwarden' should not be translated" + }, + "sponsored": { + "message": "Sponsored" } } diff --git a/libs/angular/src/admin-console/components/collections.component.ts b/libs/angular/src/admin-console/components/collections.component.ts index 445727ac617..f490fa51b6f 100644 --- a/libs/angular/src/admin-console/components/collections.component.ts +++ b/libs/angular/src/admin-console/components/collections.component.ts @@ -100,7 +100,7 @@ export class CollectionsComponent implements OnInit { this.platformUtilsService.showToast("success", null, this.i18nService.t("editedItem")); return true; } catch (e) { - this.logService.error(e); + this.platformUtilsService.showToast("error", this.i18nService.t("errorOccurred"), e.message); return false; } } diff --git a/libs/angular/src/directives/api-action.directive.ts b/libs/angular/src/directives/api-action.directive.ts index d07cb67b0fa..d7814b75427 100644 --- a/libs/angular/src/directives/api-action.directive.ts +++ b/libs/angular/src/directives/api-action.directive.ts @@ -9,6 +9,8 @@ import { ValidationService } from "@bitwarden/common/platform/abstractions/valid * Attach it to a
element and provide the name of the class property that will hold the api call promise. * e.g. * Any errors/rejections that occur will be intercepted and displayed as error toasts. + * + * @deprecated Use the CL's {@link BitSubmitDirective} instead */ @Directive({ selector: "[appApiAction]", diff --git a/libs/angular/src/directives/copy-click.directive.ts b/libs/angular/src/directives/copy-click.directive.ts index 5e449572bf8..cee2bdde4e8 100644 --- a/libs/angular/src/directives/copy-click.directive.ts +++ b/libs/angular/src/directives/copy-click.directive.ts @@ -1,16 +1,32 @@ +import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { Directive, HostListener, Input } from "@angular/core"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ToastService } from "@bitwarden/components"; @Directive({ selector: "[appCopyClick]", }) export class CopyClickDirective { - constructor(private platformUtilsService: PlatformUtilsService) {} + constructor( + private platformUtilsService: PlatformUtilsService, + private toastService: ToastService, + private i18nService: I18nService, + ) {} @Input("appCopyClick") valueToCopy = ""; + @Input({ transform: coerceBooleanProperty }) showToast?: boolean; @HostListener("click") onClick() { this.platformUtilsService.copyToClipboard(this.valueToCopy); + + if (this.showToast) { + this.toastService.showToast({ + variant: "info", + title: null, + message: this.i18nService.t("copySuccessful"), + }); + } } } diff --git a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts index 1d88e2d96d7..c22eb1febb9 100644 --- a/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts +++ b/libs/common/src/vault/abstractions/folder/folder.service.abstraction.ts @@ -17,6 +17,7 @@ export abstract class FolderService implements UserKeyRotationDataProvider Promise; encrypt: (model: FolderView, key?: SymmetricCryptoKey) => Promise; get: (id: string) => Promise; + getDecrypted$: (id: string) => Observable; getAllFromState: () => Promise; /** * @deprecated Only use in CLI! diff --git a/libs/common/src/vault/services/folder/folder.service.ts b/libs/common/src/vault/services/folder/folder.service.ts index b3cfeb3c166..17d9f39f8ec 100644 --- a/libs/common/src/vault/services/folder/folder.service.ts +++ b/libs/common/src/vault/services/folder/folder.service.ts @@ -1,4 +1,4 @@ -import { Observable, firstValueFrom, map } from "rxjs"; +import { Observable, firstValueFrom, map, shareReplay } from "rxjs"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; @@ -61,6 +61,13 @@ export class FolderService implements InternalFolderServiceAbstraction { return folders.find((folder) => folder.id === id); } + getDecrypted$(id: string): Observable { + return this.folderViews$.pipe( + map((folders) => folders.find((folder) => folder.id === id)), + shareReplay({ refCount: true, bufferSize: 1 }), + ); + } + async getAllFromState(): Promise { return await firstValueFrom(this.folders$); } diff --git a/libs/vault/src/cipher-view/additional-information/additional-information.component.html b/libs/vault/src/cipher-view/additional-information/additional-information.component.html new file mode 100644 index 00000000000..931c3578706 --- /dev/null +++ b/libs/vault/src/cipher-view/additional-information/additional-information.component.html @@ -0,0 +1,21 @@ + + +

{{ "additionalInformation" | i18n }}

+
+ + +
+ + +
+
+
diff --git a/libs/vault/src/cipher-view/additional-information/additional-information.component.ts b/libs/vault/src/cipher-view/additional-information/additional-information.component.ts new file mode 100644 index 00000000000..a9660f3fc27 --- /dev/null +++ b/libs/vault/src/cipher-view/additional-information/additional-information.component.ts @@ -0,0 +1,29 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + IconButtonModule, + CardComponent, + InputModule, + SectionComponent, + SectionHeaderComponent, +} from "@bitwarden/components"; + +@Component({ + selector: "app-additional-information", + templateUrl: "additional-information.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + CardComponent, + IconButtonModule, + InputModule, + SectionComponent, + SectionHeaderComponent, + ], +}) +export class AdditionalInformationComponent { + @Input() notes: string; +} diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.html b/libs/vault/src/cipher-view/attachments/attachments-v2.component.html new file mode 100644 index 00000000000..acce6f2622f --- /dev/null +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.html @@ -0,0 +1,32 @@ + + +

{{ "attachments" | i18n }}

+
+ + +
+

+ {{ attachment.fileName }} +

+
+ {{ attachment.sizeName }} +
+
+
+ + +
+
+
+
diff --git a/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts new file mode 100644 index 00000000000..c274fa4e9ac --- /dev/null +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts @@ -0,0 +1,153 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { NEVER, switchMap } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrgKey } from "@bitwarden/common/types/key"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + ToastService, + ItemModule, + IconButtonModule, + SectionComponent, + SectionHeaderComponent, +} from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +@Component({ + selector: "app-attachments-v2", + templateUrl: "attachments-v2.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + ItemModule, + IconButtonModule, + SectionComponent, + SectionHeaderComponent, + ], +}) +export class AttachmentsV2Component { + @Input() cipher: CipherView; + + canAccessPremium: boolean; + orgKey: OrgKey; + private passwordReprompted = false; + + constructor( + private passwordRepromptService: PasswordRepromptService, + private i18nService: I18nService, + private apiService: ApiService, + private fileDownloadService: FileDownloadService, + private cryptoService: CryptoService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private toastService: ToastService, + private stateProvider: StateProvider, + private encryptService: EncryptService, + ) { + this.subscribeToHasPremiumCheck(); + this.subscribeToOrgKey(); + } + + subscribeToHasPremiumCheck() { + this.billingAccountProfileStateService.hasPremiumFromAnySource$ + .pipe(takeUntilDestroyed()) + .subscribe((data) => { + this.canAccessPremium = data; + }); + } + + subscribeToOrgKey() { + this.stateProvider.activeUserId$ + .pipe( + switchMap((userId) => (userId != null ? this.cryptoService.orgKeys$(userId) : NEVER)), + takeUntilDestroyed(), + ) + .subscribe((data: Record | null) => { + if (data) { + this.orgKey = data[this.cipher.organizationId as OrganizationId]; + } + }); + } + + async downloadAttachment(attachment: any) { + this.passwordReprompted = + this.passwordReprompted || + (await this.passwordRepromptService.passwordRepromptCheck(this.cipher)); + if (!this.passwordReprompted) { + return; + } + const file = attachment as any; + + if (file.downloading) { + return; + } + + if (this.cipher.organizationId == null && !this.canAccessPremium) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("premiumRequired"), + message: this.i18nService.t("premiumRequiredDesc"), + }); + return; + } + + let url: string; + try { + const attachmentDownloadResponse = await this.apiService.getAttachmentData( + this.cipher.id, + attachment.id, + ); + url = attachmentDownloadResponse.url; + } catch (e) { + if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) { + url = attachment.url; + } else if (e instanceof ErrorResponse) { + throw new Error((e as ErrorResponse).getSingleMessage()); + } else { + throw e; + } + } + + file.downloading = true; + const response = await fetch(new Request(url, { cache: "no-store" })); + if (response.status !== 200) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("errorOccurred"), + }); + file.downloading = false; + return; + } + + try { + const encBuf = await EncArrayBuffer.fromResponse(response); + const key = attachment.key != null ? attachment.key : this.orgKey; + const decBuf = await this.encryptService.decryptToBytes(encBuf, key); + this.fileDownloadService.download({ + fileName: attachment.fileName, + blobData: decBuf, + }); + } catch (e) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("errorOccurred"), + }); + } + + file.downloading = false; + } +} diff --git a/libs/vault/src/cipher-view/cipher-view.component.html b/libs/vault/src/cipher-view/cipher-view.component.html new file mode 100644 index 00000000000..575d80257ea --- /dev/null +++ b/libs/vault/src/cipher-view/cipher-view.component.html @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts new file mode 100644 index 00000000000..4764b571473 --- /dev/null +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -0,0 +1,84 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; +import { Observable, Subject, takeUntil } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CollectionId } from "@bitwarden/common/types/guid"; +import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { SearchModule } from "@bitwarden/components"; + +import { PopupFooterComponent } from "../../../../apps/browser/src/platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../../apps/browser/src/platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../../apps/browser/src/platform/popup/layout/popup-page.component"; + +import { AdditionalInformationComponent } from "./additional-information/additional-information.component"; +import { AttachmentsV2Component } from "./attachments/attachments-v2.component"; +import { CustomFieldV2Component } from "./custom-fields/custom-fields-v2.component"; +import { ItemDetailsV2Component } from "./item-details/item-details-v2.component"; +import { ItemHistoryV2Component } from "./item-history/item-history-v2.component"; + +@Component({ + selector: "app-cipher-view", + templateUrl: "cipher-view.component.html", + standalone: true, + imports: [ + CommonModule, + SearchModule, + JslibModule, + PopupPageComponent, + PopupHeaderComponent, + PopupFooterComponent, + ItemDetailsV2Component, + AdditionalInformationComponent, + AttachmentsV2Component, + ItemHistoryV2Component, + CustomFieldV2Component, + ], +}) +export class CipherViewComponent implements OnInit { + @Input() cipher: CipherView; + organization$: Observable; + folder$: Observable; + collections$: Observable; + private destroyed$: Subject = new Subject(); + + constructor( + private organizationService: OrganizationService, + private collectionService: CollectionService, + private folderService: FolderService, + ) {} + + async ngOnInit() { + await this.loadCipherData(); + } + ngOnDestroy(): void { + this.destroyed$.next(); + this.destroyed$.complete(); + } + + async loadCipherData() { + if (this.cipher.collectionIds.length > 0) { + this.collections$ = this.collectionService + .decryptedCollectionViews$(this.cipher.collectionIds as CollectionId[]) + .pipe(takeUntil(this.destroyed$)); + } + + if (this.cipher.organizationId) { + this.organization$ = this.organizationService + .get$(this.cipher.organizationId) + .pipe(takeUntil(this.destroyed$)); + } + + if (this.cipher.folderId) { + this.folder$ = this.folderService + .getDecrypted$(this.cipher.folderId) + .pipe(takeUntil(this.destroyed$)); + } + } +} diff --git a/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html new file mode 100644 index 00000000000..91a380f53d5 --- /dev/null +++ b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.html @@ -0,0 +1,76 @@ + + +

{{ "customFields" | i18n }}

+
+ +
+ + +
+ + +
+
+ + + + + + + + + +
+ +
+ {{ field.name }} +
+
+
+ + +
+ + +
+
+
+
+
diff --git a/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.ts b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.ts new file mode 100644 index 00000000000..a40bca2d261 --- /dev/null +++ b/libs/vault/src/cipher-view/custom-fields/custom-fields-v2.component.ts @@ -0,0 +1,47 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FieldType, LinkedIdType, LoginLinkedId } from "@bitwarden/common/vault/enums"; +import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; +import { + CardComponent, + IconButtonModule, + FormFieldModule, + InputModule, + SectionComponent, + SectionHeaderComponent, +} from "@bitwarden/components"; + +@Component({ + selector: "app-custom-fields-v2", + templateUrl: "custom-fields-v2.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + CardComponent, + IconButtonModule, + FormFieldModule, + InputModule, + SectionComponent, + SectionHeaderComponent, + ], +}) +export class CustomFieldV2Component { + @Input() fields: FieldView[]; + fieldType = FieldType; + + constructor(private i18nService: I18nService) {} + + getLinkedType(linkedId: LinkedIdType) { + if (linkedId === LoginLinkedId.Username) { + return this.i18nService.t("username"); + } + + if (linkedId === LoginLinkedId.Password) { + return this.i18nService.t("password"); + } + } +} diff --git a/libs/vault/src/cipher-view/index.ts b/libs/vault/src/cipher-view/index.ts new file mode 100644 index 00000000000..8231f5c1611 --- /dev/null +++ b/libs/vault/src/cipher-view/index.ts @@ -0,0 +1 @@ +export * from "./cipher-view.component"; diff --git a/libs/vault/src/cipher-view/item-details/item-details-v2.component.html b/libs/vault/src/cipher-view/item-details/item-details-v2.component.html new file mode 100644 index 00000000000..0ade00679af --- /dev/null +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.html @@ -0,0 +1,33 @@ + + +

{{ "itemDetails" | i18n }}

+
+ +
+ + +
+ +
+
+ {{ "ownerYou" | i18n }} +
+
+ {{ organization.name }} +
+
+

+ {{ collection.name }} +

+
+
+ {{ folder.name }} +
+
+
+
diff --git a/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts new file mode 100644 index 00000000000..b0d158c1409 --- /dev/null +++ b/libs/vault/src/cipher-view/item-details/item-details-v2.component.ts @@ -0,0 +1,22 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { CardComponent, SectionComponent, SectionHeaderComponent } from "@bitwarden/components"; + +@Component({ + selector: "app-item-details-v2", + templateUrl: "item-details-v2.component.html", + standalone: true, + imports: [CommonModule, JslibModule, CardComponent, SectionComponent, SectionHeaderComponent], +}) +export class ItemDetailsV2Component { + @Input() cipher: CipherView; + @Input() organization?: Organization; + @Input() collections?: CollectionView[]; + @Input() folder?: FolderView; +} diff --git a/libs/vault/src/cipher-view/item-history/item-history-v2.component.html b/libs/vault/src/cipher-view/item-history/item-history-v2.component.html new file mode 100644 index 00000000000..c1e11b9e58b --- /dev/null +++ b/libs/vault/src/cipher-view/item-history/item-history-v2.component.html @@ -0,0 +1,27 @@ + + +

{{ "itemHistory" | i18n }}

+
+ +

+ {{ "lastEdited" | i18n }}: + {{ cipher.revisionDate | date: "medium" }} +

+

+ {{ "dateCreated" | i18n }}: + {{ cipher.creationDate | date: "medium" }} +

+

+ {{ "datePasswordUpdated" | i18n }}: + {{ cipher.passwordRevisionDisplayDate | date: "medium" }} +

+ + {{ "passwordHistory" | i18n }} + +
+
diff --git a/libs/vault/src/cipher-view/item-history/item-history-v2.component.ts b/libs/vault/src/cipher-view/item-history/item-history-v2.component.ts new file mode 100644 index 00000000000..51badfdbc89 --- /dev/null +++ b/libs/vault/src/cipher-view/item-history/item-history-v2.component.ts @@ -0,0 +1,24 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input } from "@angular/core"; +import { RouterModule } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CardComponent, SectionComponent, SectionHeaderComponent } from "@bitwarden/components"; + +@Component({ + selector: "app-item-history-v2", + templateUrl: "item-history-v2.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + RouterModule, + CardComponent, + SectionComponent, + SectionHeaderComponent, + ], +}) +export class ItemHistoryV2Component { + @Input() cipher: CipherView; +} diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 82b20bbe538..e4e17e7aa5a 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -2,4 +2,5 @@ export { PasswordRepromptService } from "./services/password-reprompt.service"; export { CopyCipherFieldService, CopyAction } from "./services/copy-cipher-field.service"; export { CopyCipherFieldDirective } from "./components/copy-cipher-field.directive"; +export * from "./cipher-view"; export * from "./cipher-form"; diff --git a/libs/vault/src/services/password-reprompt.service.ts b/libs/vault/src/services/password-reprompt.service.ts index 6aba602c16f..8621c436bae 100644 --- a/libs/vault/src/services/password-reprompt.service.ts +++ b/libs/vault/src/services/password-reprompt.service.ts @@ -2,6 +2,8 @@ import { Injectable } from "@angular/core"; import { lastValueFrom } from "rxjs"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptComponent } from "../components/password-reprompt.component"; @@ -21,6 +23,14 @@ export class PasswordRepromptService { return ["TOTP", "Password", "H_Field", "Card Number", "Security Code"]; } + async passwordRepromptCheck(cipher: CipherView) { + if (cipher.reprompt === CipherRepromptType.None) { + return true; + } + + return await this.showPasswordPrompt(); + } + async showPasswordPrompt() { if (!(await this.enabled())) { return true;