1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-22 04:13:49 +00:00

[PM-26515] - Browser - Non Premium User Archived Item Flow (#16908)

* non-premium user flow  archived items

* add archived button

* update archive service

* fix add-edit component

* fix tests

* fix tests

* small fixes

* remove unused service

* fix test

* fix test

* fix test

* fix tests

* only show archived badge when user cannot archive

* update spec

* add test

* revert change to button

* use previouslyCouldArchive

* fix tests

* hide clone button when data ownership policy is enabled

* remove dupe pipe. fix logic

* change from button to span

* fix logic

* fix tests and logic

* fix tests. simplify logic

* updates to archive component

* fix archived pill logic

* fix add missing pop-out

* cleanup

* check if cipher is present in template

* remove enforceDataOwnershipPolicy obs
This commit is contained in:
Jordan Aasen
2026-01-14 14:38:46 -08:00
committed by GitHub
parent b1b6aa2e75
commit c91fbb2cad
9 changed files with 172 additions and 17 deletions

View File

@@ -585,6 +585,12 @@
"archiveItemConfirmDesc": {
"message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?"
},
"archived": {
"message": "Archived"
},
"unarchiveAndSave": {
"message": "Unarchive and save"
},
"upgradeToUseArchive": {
"message": "A premium membership is required to use Archive."
},
@@ -1539,6 +1545,15 @@
"premiumSignUpTwoStepOptions": {
"message": "Proprietary two-step login options such as YubiKey and Duo."
},
"premiumSubscriptionEnded": {
"message": "Your Premium subscription ended"
},
"archivePremiumRestart": {
"message": "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, it'll be moved back into your vault."
},
"restartPremium": {
"message": "Restart Premium"
},
"ppremiumSignUpReports": {
"message": "Password hygiene, account health, and data breach reports to keep your vault safe."
},

View File

@@ -1,3 +1,5 @@
@let previouslyCouldArchive = !(userCanArchive$ | async) && config?.originalCipher?.archivedDate;
<popup-page>
<popup-header
slot="header"
@@ -5,6 +7,13 @@
[backAction]="handleBackButton"
showBackButton
>
@if (config?.originalCipher?.archivedDate) {
<ng-container slot="end">
<span bitBadge variant="secondary" [appA11yTitle]="'archived' | i18n">
{{ "archived" | i18n }}
</span>
</ng-container>
}
<app-pop-out slot="end" />
</popup-header>
@@ -24,7 +33,7 @@
<popup-footer slot="footer">
<button bitButton type="submit" form="cipherForm" buttonType="primary" #submitBtn>
{{ "save" | i18n }}
{{ (previouslyCouldArchive ? "unarchiveAndSave" : "save") | i18n }}
</button>
<button (click)="handleBackButton()" bitButton type="button" buttonType="secondary">

View File

@@ -4,8 +4,10 @@ import { ActivatedRoute, Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/view-cache";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { EventType } from "@bitwarden/common/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -19,6 +21,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info";
import { DialogService } from "@bitwarden/components";
import {
@@ -95,6 +98,18 @@ describe("AddEditV2Component", () => {
},
},
{ provide: AccountService, useValue: mockAccountServiceWith("UserId" as UserId) },
{
provide: TaskService,
useValue: mock<TaskService>(),
},
{
provide: ViewCacheService,
useValue: { signal: jest.fn(() => (): any => null) },
},
{
provide: BillingAccountProfileStateService,
useValue: mock<BillingAccountProfileStateService>(),
},
{
provide: CipherArchiveService,
useValue: cipherArchiveService,
@@ -380,6 +395,46 @@ describe("AddEditV2Component", () => {
});
});
describe("submit button text", () => {
beforeEach(() => {
// prevent form from rendering
jest.spyOn(component as any, "loading", "get").mockReturnValue(true);
});
it("sets it to 'save' by default", fakeAsync(() => {
buildConfigResponse.originalCipher = {} as Cipher;
queryParams$.next({});
tick();
const submitBtn = fixture.debugElement.query(By.css("button[type=submit]"));
expect(submitBtn.nativeElement.textContent.trim()).toBe("save");
}));
it("sets it to 'save' when the user is able to archive the item", fakeAsync(() => {
buildConfigResponse.originalCipher = { isArchived: false } as any;
queryParams$.next({});
tick();
const submitBtn = fixture.debugElement.query(By.css("button[type=submit]"));
expect(submitBtn.nativeElement.textContent.trim()).toBe("save");
}));
it("sets it to 'unarchiveAndSave' when the user cannot archive and the item is archived", fakeAsync(() => {
cipherArchiveService.userCanArchive$.mockReturnValue(of(false));
buildConfigResponse.originalCipher = { isArchived: true } as any;
queryParams$.next({});
tick();
const submitBtn = fixture.debugElement.query(By.css("button[type=submit]"));
expect(submitBtn.nativeElement.textContent.trim()).toBe("save");
}));
});
describe("archive", () => {
it("calls archiveCipherUtilsService service to archive the cipher", async () => {
buildConfigResponse.originalCipher = { id: "222-333-444-5555", edit: true } as Cipher;

View File

@@ -30,6 +30,7 @@ import {
IconButtonModule,
DialogService,
ToastService,
BadgeModule,
} from "@bitwarden/components";
import {
ArchiveCipherUtilitiesService,
@@ -159,6 +160,7 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
AsyncActionsModule,
PopOutComponent,
IconButtonModule,
BadgeModule,
],
})
export class AddEditV2Component implements OnInit, OnDestroy {
@@ -190,6 +192,11 @@ export class AddEditV2Component implements OnInit, OnDestroy {
private fido2PopoutSessionData$ = fido2PopoutSessionData$();
private fido2PopoutSessionData: Fido2SessionData;
protected userId$ = this.accountService.activeAccount$.pipe(getUserId);
protected userCanArchive$ = this.userId$.pipe(
switchMap((userId) => this.archiveService.userCanArchive$(userId)),
);
private get inFido2PopoutWindow() {
return BrowserPopupUtils.inPopout(window) && this.fido2PopoutSessionData.isFido2Session;
}
@@ -200,14 +207,6 @@ export class AddEditV2Component implements OnInit, OnDestroy {
protected archiveFlagEnabled$ = this.archiveService.hasArchiveFlagEnabled$;
/**
* Flag to indicate if the user can archive items.
* @protected
*/
protected userCanArchive$ = this.accountService.activeAccount$.pipe(
switchMap((account) => this.archiveService.userCanArchive$(account.id)),
);
constructor(
private route: ActivatedRoute,
private i18nService: I18nService,
@@ -377,9 +376,7 @@ export class AddEditV2Component implements OnInit, OnDestroy {
}
config.initialValues = await this.setInitialValuesFromParams(params);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(getUserId),
);
const activeUserId = await firstValueFrom(this.userId$);
// The browser notification bar and overlay use addEditCipherInfo$ to pass modified cipher details to the form
// Attempt to fetch them here and overwrite the initialValues if present
@@ -510,7 +507,7 @@ export class AddEditV2Component implements OnInit, OnDestroy {
}
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const activeUserId = await firstValueFrom(this.userId$);
await this.deleteCipher(activeUserId);
} catch (e) {
this.logService.error(e);

View File

@@ -1,6 +1,13 @@
<popup-page>
<popup-header slot="header" [pageTitle]="headerText" showBackButton>
<app-pop-out slot="end" />
<ng-container slot="end">
@if (cipher?.isArchived) {
<span bitBadge variant="secondary" [appA11yTitle]="'archived' | i18n">
{{ "archived" | i18n }}
</span>
}
<app-pop-out />
</ng-container>
</popup-header>
@if (cipher) {

View File

@@ -78,8 +78,9 @@ describe("ViewV2Component", () => {
username: "test-username",
password: "test-password",
totp: "123",
uris: [],
uris: ["https://example.com"],
},
permissions: {},
card: {},
} as unknown as CipherView;
@@ -155,7 +156,7 @@ describe("ViewV2Component", () => {
{
provide: CipherAuthorizationService,
useValue: {
canDeleteCipher$: jest.fn().mockReturnValue(true),
canDeleteCipher$: jest.fn().mockReturnValue(of(true)),
},
},
{
@@ -644,4 +645,44 @@ describe("ViewV2Component", () => {
});
});
});
describe("archived badge", () => {
it("shows archived badge if the cipher is archived", fakeAsync(() => {
component.cipher = { ...mockCipher, isArchived: true } as CipherView;
mockCipherService.cipherViews$.mockImplementationOnce(() =>
of([
{
...mockCipher,
isArchived: true,
},
]),
);
params$.next({ action: "view", cipherId: mockCipher.id });
flush();
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector("span[bitBadge]");
expect(badge).toBeTruthy();
}));
it("does not show archived badge if the cipher is not archived", () => {
component.cipher = { ...mockCipher, isArchived: false } as CipherView;
mockCipherService.cipherViews$.mockImplementationOnce(() =>
of([
{
...mockCipher,
archivedDate: new Date(),
},
]),
);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector("span[bitBadge]");
expect(badge).toBeFalsy();
});
});
});

View File

@@ -35,6 +35,7 @@ import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cip
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import {
AsyncActionsModule,
BadgeModule,
ButtonModule,
CalloutModule,
DialogService,
@@ -97,6 +98,7 @@ type LoadAction =
AsyncActionsModule,
PopOutComponent,
CalloutModule,
BadgeModule,
],
providers: [
{ provide: ViewPasswordHistoryService, useClass: BrowserViewPasswordHistoryService },

View File

@@ -5,6 +5,21 @@
</ng-container>
</popup-header>
@if (showSubscriptionEndedMessaging$ | async) {
<bit-card class="tw-mb-4">
<div class="tw-flex tw-gap-2 tw-items-baseline">
<i class="bwi bwi-info-circle" aria-hidden="true"></i>
<h3 class="tw-font-[500]" bitTypography="h5">{{ "premiumSubscriptionEnded" | i18n }}</h3>
</div>
<p bitTypography="body2" class="tw-mb-3">
{{ "archivePremiumRestart" | i18n }}
</p>
<button type="button" bitButton (click)="navigateToPremium()" buttonType="primary">
{{ "restartPremium" | i18n }}
</button>
</bit-card>
}
@if (archivedCiphers$ | async; as archivedItems) {
@if (archivedItems.length) {
<bit-section>

View File

@@ -25,6 +25,8 @@ import {
SectionHeaderComponent,
ToastService,
TypographyModule,
CardComponent,
ButtonComponent,
} from "@bitwarden/components";
import {
CanDeleteCipherDirective,
@@ -55,6 +57,8 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
SectionComponent,
SectionHeaderComponent,
TypographyModule,
CardComponent,
ButtonComponent,
],
})
export class ArchiveComponent {
@@ -67,13 +71,15 @@ export class ArchiveComponent {
private i18nService = inject(I18nService);
private cipherArchiveService = inject(CipherArchiveService);
private passwordRepromptService = inject(PasswordRepromptService);
private userId$: Observable<UserId> = this.accountService.activeAccount$.pipe(getUserId);
protected archivedCiphers$ = this.userId$.pipe(
switchMap((userId) => this.cipherArchiveService.archivedCiphers$(userId)),
);
protected userCanArchive$ = this.userId$.pipe(
switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)),
);
protected CipherViewLikeUtils = CipherViewLikeUtils;
protected loading$ = this.archivedCiphers$.pipe(
@@ -81,6 +87,14 @@ export class ArchiveComponent {
startWith(true),
);
protected showSubscriptionEndedMessaging$ = this.userId$.pipe(
switchMap((userId) => this.cipherArchiveService.showSubscriptionEndedMessaging$(userId)),
);
async navigateToPremium() {
await this.router.navigate(["/premium"]);
}
async view(cipher: CipherViewLike) {
if (!(await this.canInteract(cipher))) {
return;