1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-13 06:43:35 +00:00

[PM-26359] Archive Upgrade - Browser (#16904)

* add archive upgrade flow to more options menu

* add reprompt for archiving a cipher

* add premium badge for archive in settings

* update showArchive to only look at the feature flag

* add premium badge for browser settings

* add event to prompt for premium

* formatting

* update test
This commit is contained in:
Nick Krantz
2025-11-25 11:28:34 -06:00
committed by GitHub
parent 17ae78ea83
commit 441783627b
6 changed files with 90 additions and 31 deletions

View File

@@ -585,6 +585,9 @@
"archiveItemConfirmDesc": { "archiveItemConfirmDesc": {
"message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?" "message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?"
}, },
"upgradeToUseArchive": {
"message": "A premium membership is required to use Archive."
},
"edit": { "edit": {
"message": "Edit" "message": "Edit"
}, },

View File

@@ -51,10 +51,26 @@
{{ "assignToCollections" | i18n }} {{ "assignToCollections" | i18n }}
</a> </a>
</ng-container> </ng-container>
@if (canArchive$ | async) { @if (showArchive$ | async) {
<button type="button" bitMenuItem (click)="archive()"> @if (canArchive$ | async) {
{{ "archiveVerb" | i18n }} <button type="button" bitMenuItem (click)="archive()">
</button> {{ "archiveVerb" | i18n }}
</button>
} @else {
<button
type="button"
bitMenuItem
(click)="badge.promptForPremium($event)"
[attr.aria-label]="'upgradeToUseArchive' | i18n"
>
<div class="tw-flex tw-flex-nowrap tw-items-center tw-gap-2">
{{ "archiveVerb" | i18n }}
<div aria-hidden>
<app-premium-badge #badge></app-premium-badge>
</div>
</div>
</button>
}
} }
@if (canDelete$ | async) { @if (canDelete$ | async) {
<button type="button" bitMenuItem (click)="delete()"> <button type="button" bitMenuItem (click)="delete()">

View File

@@ -106,7 +106,10 @@ describe("ItemMoreOptionsComponent", () => {
}, },
{ provide: CollectionService, useValue: { decryptedCollections$: () => of([]) } }, { provide: CollectionService, useValue: { decryptedCollections$: () => of([]) } },
{ provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } }, { provide: RestrictedItemTypesService, useValue: { restricted$: of([]) } },
{ provide: CipherArchiveService, useValue: { userCanArchive$: () => of(true) } }, {
provide: CipherArchiveService,
useValue: { userCanArchive$: () => of(true), hasArchiveFlagEnabled$: () => of(true) },
},
{ provide: ToastService, useValue: { showToast: () => {} } }, { provide: ToastService, useValue: { showToast: () => {} } },
{ provide: Router, useValue: { navigate: () => Promise.resolve(true) } }, { provide: Router, useValue: { navigate: () => Promise.resolve(true) } },
{ provide: PasswordRepromptService, useValue: passwordRepromptService }, { provide: PasswordRepromptService, useValue: passwordRepromptService },

View File

@@ -1,10 +1,11 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, Input } from "@angular/core"; import { booleanAttribute, Component, Input } from "@angular/core";
import { Router, RouterModule } from "@angular/router"; import { Router, RouterModule } from "@angular/router";
import { BehaviorSubject, combineLatest, firstValueFrom, map, switchMap } from "rxjs"; import { BehaviorSubject, combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { filter } from "rxjs/operators"; import { filter } from "rxjs/operators";
import { CollectionService } from "@bitwarden/admin-console/common"; import { CollectionService } from "@bitwarden/admin-console/common";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -17,6 +18,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
@@ -33,6 +35,7 @@ import {
} from "@bitwarden/components"; } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault"; import { PasswordRepromptService } from "@bitwarden/vault";
import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service";
import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service"; import { VaultPopupAutofillService } from "../../../services/vault-popup-autofill.service";
import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; import { VaultPopupItemsService } from "../../../services/vault-popup-items.service";
import { AddEditQueryParams } from "../add-edit/add-edit-v2.component"; import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
@@ -46,7 +49,18 @@ import {
@Component({ @Component({
selector: "app-item-more-options", selector: "app-item-more-options",
templateUrl: "./item-more-options.component.html", templateUrl: "./item-more-options.component.html",
imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule], imports: [
ItemModule,
IconButtonModule,
MenuModule,
CommonModule,
JslibModule,
RouterModule,
PremiumBadgeComponent,
],
providers: [
{ provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService },
],
}) })
export class ItemMoreOptionsComponent { export class ItemMoreOptionsComponent {
private _cipher$ = new BehaviorSubject<CipherViewLike>({} as CipherViewLike); private _cipher$ = new BehaviorSubject<CipherViewLike>({} as CipherViewLike);
@@ -127,18 +141,11 @@ export class ItemMoreOptionsComponent {
}), }),
); );
/** Observable Boolean checking if item can show Archive menu option */ protected showArchive$: Observable<boolean> = this.cipherArchiveService.hasArchiveFlagEnabled$();
protected canArchive$ = combineLatest([
this._cipher$, protected canArchive$: Observable<boolean> = this.accountService.activeAccount$.pipe(
this.accountService.activeAccount$.pipe( getUserId,
getUserId, switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)),
switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)),
),
]).pipe(
filter(([cipher, userId]) => cipher != null && userId != null),
map(([cipher, canArchive]) => {
return canArchive && !CipherViewLikeUtils.isArchived(cipher) && cipher.organizationId == null;
}),
); );
protected canDelete$ = this._cipher$.pipe( protected canDelete$ = this._cipher$.pipe(
@@ -377,6 +384,11 @@ export class ItemMoreOptionsComponent {
} }
async archive() { async archive() {
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(this.cipher);
if (!repromptPassed) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({ const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "archiveItem" }, title: { key: "archiveItem" },
content: { key: "archiveItemConfirmDesc" }, content: { key: "archiveItemConfirmDesc" },

View File

@@ -34,13 +34,27 @@
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i> <i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a> </a>
</bit-item> </bit-item>
@if (userCanArchive() || showArchiveFilter()) { @if (showArchiveItem()) {
<bit-item> @if (userCanArchive()) {
<a bit-item-content routerLink="/archive"> <bit-item>
{{ "archiveNoun" | i18n }} <a bit-item-content routerLink="/archive">
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i> {{ "archiveNoun" | i18n }}
</a> <i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</bit-item> </a>
</bit-item>
} @else {
<bit-item>
<a bit-item-content [routerLink]="userHasArchivedItems() ? '/archive' : '/premium'">
<span class="tw-flex tw-items-center tw-gap-2">
{{ "archiveNoun" | i18n }}
@if (!userHasArchivedItems()) {
<app-premium-badge></app-premium-badge>
}
</span>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
}
} }
<bit-item> <bit-item>
<a bit-item-content routerLink="/trash"> <a bit-item-content routerLink="/trash">

View File

@@ -2,14 +2,16 @@ import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component, OnDestroy, OnInit } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop"; import { toSignal } from "@angular/core/rxjs-interop";
import { Router, RouterModule } from "@angular/router"; import { Router, RouterModule } from "@angular/router";
import { firstValueFrom, switchMap } from "rxjs"; import { firstValueFrom, map, switchMap } from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { BadgeComponent, ItemModule, ToastOptions, ToastService } from "@bitwarden/components"; import { BadgeComponent, ItemModule, ToastOptions, ToastService } from "@bitwarden/components";
@@ -18,6 +20,7 @@ import BrowserPopupUtils from "../../../platform/browser/browser-popup-utils";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { BrowserPremiumUpgradePromptService } from "../services/browser-premium-upgrade-prompt.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -32,20 +35,28 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
PopOutComponent, PopOutComponent,
ItemModule, ItemModule,
BadgeComponent, BadgeComponent,
PremiumBadgeComponent,
],
providers: [
{ provide: PremiumUpgradePromptService, useClass: BrowserPremiumUpgradePromptService },
], ],
}) })
export class VaultSettingsV2Component implements OnInit, OnDestroy { export class VaultSettingsV2Component implements OnInit, OnDestroy {
lastSync = "--"; lastSync = "--";
private userId$ = this.accountService.activeAccount$.pipe(getUserId); private userId$ = this.accountService.activeAccount$.pipe(getUserId);
// Check if user is premium user, they will be able to archive items
protected readonly userCanArchive = toSignal( protected readonly userCanArchive = toSignal(
this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))), this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))),
); );
// Check if user has archived items (does not check if user is premium) protected readonly showArchiveItem = toSignal(this.cipherArchiveService.hasArchiveFlagEnabled$());
protected readonly showArchiveFilter = toSignal(
this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.showArchiveVault$(userId))), protected readonly userHasArchivedItems = toSignal(
this.userId$.pipe(
switchMap((userId) =>
this.cipherArchiveService.archivedCiphers$(userId).pipe(map((c) => c.length > 0)),
),
),
); );
protected emptyVaultImportBadge$ = this.accountService.activeAccount$.pipe( protected emptyVaultImportBadge$ = this.accountService.activeAccount$.pipe(