mirror of
https://github.com/bitwarden/browser
synced 2026-01-21 20:03:43 +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:
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user