From d130c443b8effd189cd79b0b0267b7973a3ac573 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Mon, 15 Dec 2025 18:16:04 -0500 Subject: [PATCH] [PM-26514] Archive With Non Premium User (#17820) * Add callout for archive non premium, add premium check, add archive badge to view/edit modal, update btn text --- .../vault-item-dialog.component.html | 126 ++++++++++-------- .../vault-item-dialog.component.spec.ts | 36 +++++ .../vault-item-dialog.component.ts | 24 +++- .../vault-cipher-row.component.html | 2 +- .../vault-items/vault-cipher-row.component.ts | 18 +-- .../individual-vault/vault.component.html | 30 +++-- .../vault/individual-vault/vault.component.ts | 4 + apps/web/src/locales/en/messages.json | 3 + .../src/dialog/dialog/dialog.component.html | 25 ++-- .../src/dialog/dialog/dialog.stories.ts | 40 ++++++ 10 files changed, 219 insertions(+), 89 deletions(-) diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html index 6eb9b89b05b..3aa2f4b3bc1 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.html @@ -2,43 +2,53 @@ {{ title }} + @if (cipherIsArchived) { + {{ "archiveNoun" | i18n }} + } +
- - - - - - + @if (showCipherView) { + + } + + @if (loadForm) { + + + + + + }
- - + @if (showRestore) { + + } + + @if (showEdit) { - + } + - - -
- -
+ + @if (showCancel) { + + } + + @if (showClose) { + + } + + @if (showDelete) { +
+ +
+ }
diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts index 6716cde629a..11862b569fc 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { provideNoopAnimations } from "@angular/platform-browser/animations"; import { ActivatedRoute, Router } from "@angular/router"; +import { of } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -95,6 +96,10 @@ describe("VaultItemDialogComponent", () => { fixture = TestBed.createComponent(TestVaultItemDialogComponent); component = fixture.componentInstance; + Object.defineProperty(component, "userHasPremium$", { + get: () => of(false), + configurable: true, + }); fixture.detectChanges(); }); @@ -135,4 +140,35 @@ describe("VaultItemDialogComponent", () => { expect(component.getTestTitle()).toBe("newItemHeaderCard"); }); }); + describe("submitButtonText$", () => { + it("should return 'unArchiveAndSave' when premium is false and cipher is archived", (done) => { + jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(false)); + component["cipherIsArchived"] = true; + + component["submitButtonText$"].subscribe((text) => { + expect(text).toBe("unArchiveAndSave"); + done(); + }); + }); + + it("should return 'save' when cipher is archived and user has premium", (done) => { + jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(true)); + component["cipherIsArchived"] = true; + + component["submitButtonText$"].subscribe((text) => { + expect(text).toBe("save"); + done(); + }); + }); + + it("should return 'save' when cipher is not archived", (done) => { + jest.spyOn(component as any, "userHasPremium$", "get").mockReturnValue(of(false)); + component["cipherIsArchived"] = false; + + component["submitButtonText$"].subscribe((text) => { + expect(text).toBe("save"); + done(); + }); + }); + }); }); diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 8508596a67b..d7b9ee97123 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -12,7 +12,7 @@ import { } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; -import { firstValueFrom, Subject, switchMap } from "rxjs"; +import { firstValueFrom, Observable, Subject, switchMap } from "rxjs"; import { map } from "rxjs/operators"; import { CollectionView } from "@bitwarden/admin-console/common"; @@ -222,10 +222,10 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { protected collections?: CollectionView[]; /** - * Flag to indicate if the user has access to attachments via a premium subscription. + * Flag to indicate if the user has a premium subscription. Using for access to attachments, and archives * @protected */ - protected canAccessAttachments$ = this.accountService.activeAccount$.pipe( + protected userHasPremium$ = this.accountService.activeAccount$.pipe( switchMap((account) => this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), ), @@ -253,6 +253,8 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { protected showRestore: boolean; + protected cipherIsArchived: boolean; + protected get loadingForm() { return this.loadForm && !this.formReady; } @@ -278,6 +280,16 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { return this.cipher != undefined && (this.params.mode === "view" || this.loadingForm); } + protected get submitButtonText$(): Observable { + return this.userHasPremium$.pipe( + map((hasPremium) => + this.cipherIsArchived && !hasPremium + ? this.i18nService.t("unArchiveAndSave") + : this.i18nService.t("save"), + ), + ); + } + /** * Flag to initialize/attach the form component. */ @@ -363,6 +375,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { this.filter = await firstValueFrom(this.routedVaultFilterService.filter$); this.showRestore = await this.canUserRestore(); + this.cipherIsArchived = this.cipher.isArchived; this.performingInitialLoad = false; } @@ -392,6 +405,9 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { cipherView.collectionIds?.includes(c.id), ); + // Track cipher archive state for btn text and badge updates + this.cipherIsArchived = this.cipher.isArchived; + // If the cipher was newly created (via add/clone), switch the form to edit for subsequent edits. if (this._originalFormMode === "add" || this._originalFormMode === "clone") { this.formConfig.mode = "edit"; @@ -468,7 +484,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy { }; openAttachmentsDialog = async () => { - const canAccessAttachments = await firstValueFrom(this.canAccessAttachments$); + const canAccessAttachments = await firstValueFrom(this.userHasPremium$); if (!canAccessAttachments) { await this.premiumUpgradeService.promptForPremium(this.cipher?.organizationId); diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index d56c9d15cff..5b9f9db3e62 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -172,7 +172,7 @@ @if (!viewingOrgVault) { - diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts index 92c49ac218a..a723f1e942b 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.ts @@ -253,14 +253,6 @@ export class VaultCipherRowComponent implements OnInit ); } - protected get hasPasswordToCopy() { - return CipherViewLikeUtils.hasCopyableValue(this.cipher, "password"); - } - - protected get hasUsernameToCopy() { - return CipherViewLikeUtils.hasCopyableValue(this.cipher, "username"); - } - protected get permissionText() { if (!this.cipher.organizationId || this.cipher.collectionIds.length === 0) { return this.i18nService.t("manageCollection"); @@ -319,6 +311,9 @@ export class VaultCipherRowComponent implements OnInit } protected get isIdentityCipher() { + if (CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) { + return false; + } return CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Identity && !this.isDeleted; } @@ -394,6 +389,13 @@ export class VaultCipherRowComponent implements OnInit return this.organization.canEditAllCiphers || (this.cipher.edit && this.cipher.viewPassword); } + protected get showFavorite() { + if (CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) { + return false; + } + return true; + } + protected toggleFavorite() { this.onEvent.emit({ type: "toggleFavorite", diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index 522b63c21fd..df1b727154f 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -31,19 +31,23 @@ >
- - {{ trashCleanupWarning }} - - -

{{ "premiumSubscriptionEndedDesc" | i18n }}

- {{ - "restartPremium" | i18n - }} -
+ @if (activeFilter.isDeleted) { + + {{ trashCleanupWarning }} + + } + + @if (showSubscriptionEndedMessaging$ | async) { + + +
+ {{ "premiumSubscriptionEndedDesc" | i18n }} +
+ {{ "restartPremium" | i18n }} +
+
+ } + implements OnInit, OnDestr this.vaultFilterService.clearOrganizationFilter(); } + async navigateToGetPremium() { + await this.router.navigate(["/settings/subscription/premium"]); + } + async onVaultItemsEvent(event: VaultItemEvent) { this.processingEvent = true; try { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 0f8b0c1b466..680c28f0747 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -11591,6 +11591,9 @@ "unArchive": { "message": "Unarchive" }, + "unArchiveAndSave": { + "message": "Unarchive and save" + }, "itemsInArchive": { "message": "Items in archive" }, diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index be946c76a57..22aa99c44cb 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -34,16 +34,21 @@ } - @if (!this.dialogRef?.disableClose) { - - } + +
+ + + @if (!this.dialogRef?.disableClose) { + + } +
Foobar + Dialog body text goes here. @@ -292,3 +293,42 @@ export const WithCards: Story = { disableAnimations: true, }, }; + +export const HeaderEnd: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + Archived + + + Dialog body text goes here. + + + + + + + `, + }), + args: { + dialogSize: "small", + title: "Very Long Title That Should Be Truncated After Two Lines To Test Header End Slot", + subtitle: "Subtitle", + }, +};