Skip to content

Commit 1c1f901

Browse files
show premium subscription ended message when user has archived ciphers
1 parent 3284e27 commit 1c1f901

File tree

10 files changed

+127
-30
lines changed

10 files changed

+127
-30
lines changed

apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
1111
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
1212
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
1313
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
14+
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
1415
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
1516
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
1617
import { DialogService, ToastService } from "@bitwarden/components";
@@ -55,6 +56,7 @@ export class VaultFilterComponent
5556
protected restrictedItemTypesService: RestrictedItemTypesService,
5657
protected cipherService: CipherService,
5758
protected cipherArchiveService: CipherArchiveService,
59+
premiumUpgradePromptService: PremiumUpgradePromptService,
5860
) {
5961
super(
6062
vaultFilterService,
@@ -68,6 +70,7 @@ export class VaultFilterComponent
6870
restrictedItemTypesService,
6971
cipherService,
7072
cipherArchiveService,
73+
premiumUpgradePromptService,
7174
);
7275
}
7376

apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
1919
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
2020
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
2121
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
22+
import { UserId } from "@bitwarden/common/types/guid";
2223
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
2324
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
25+
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
2426
import { CipherType } from "@bitwarden/common/vault/enums";
2527
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
2628
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
@@ -160,6 +162,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
160162
protected restrictedItemTypesService: RestrictedItemTypesService,
161163
protected cipherService: CipherService,
162164
protected cipherArchiveService: CipherArchiveService,
165+
private premiumUpgradePromptService: PremiumUpgradePromptService,
163166
) {}
164167

165168
async ngOnInit(): Promise<void> {
@@ -242,13 +245,14 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
242245
};
243246

244247
async buildAllFilters(): Promise<VaultFilterList> {
248+
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
245249
const builderFilter = {} as VaultFilterList;
246250
builderFilter.organizationFilter = await this.addOrganizationFilter();
247251
builderFilter.typeFilter = await this.addTypeFilter();
248252
builderFilter.folderFilter = await this.addFolderFilter();
249253
builderFilter.collectionFilter = await this.addCollectionFilter();
250254
if (await firstValueFrom(this.cipherArchiveService.showArchiveFeatures$())) {
251-
builderFilter.archiveFilter = await this.addArchiveFilter();
255+
builderFilter.archiveFilter = await this.addArchiveFilter(userId);
252256
}
253257
builderFilter.trashFilter = await this.addTrashFilter();
254258
return builderFilter;
@@ -408,7 +412,17 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
408412
return trashFilterSection;
409413
}
410414

411-
protected async addArchiveFilter(): Promise<VaultFilterSection> {
415+
protected async addArchiveFilter(userId: UserId): Promise<VaultFilterSection> {
416+
const hasArchivedCiphers = await firstValueFrom(
417+
this.cipherArchiveService
418+
.archivedCiphers$(userId)
419+
.pipe(map((archivedCiphers) => archivedCiphers.length > 0)),
420+
);
421+
422+
const userHasPremium = await firstValueFrom(this.cipherArchiveService.userHasPremium$(userId));
423+
424+
const premiumPromptOnFilter = !userHasPremium && !hasArchivedCiphers;
425+
412426
const archiveFilterSection: VaultFilterSection = {
413427
data$: this.vaultFilterService.buildTypeTree(
414428
{
@@ -431,7 +445,12 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
431445
isSelectable: true,
432446
},
433447
action: this.applyTypeFilter as (filterNode: TreeNode<VaultFilterType>) => Promise<void>,
434-
premiumFeature: true,
448+
premiumOptions: {
449+
showPremiumBadge: true,
450+
blockFilterAction: premiumPromptOnFilter
451+
? async () => await this.premiumUpgradePromptService.promptForPremium()
452+
: undefined,
453+
},
435454
};
436455
return archiveFilterSection;
437456
}

apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,9 @@
11
// FIXME: Update this file to be type safe and remove this and next line
22
// @ts-strict-ignore
3-
import {
4-
Component,
5-
InjectionToken,
6-
Injector,
7-
Input,
8-
OnDestroy,
9-
OnInit,
10-
ViewChild,
11-
} from "@angular/core";
3+
import { Component, InjectionToken, Injector, Input, OnDestroy, OnInit } from "@angular/core";
124
import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
135
import { map } from "rxjs/operators";
146

15-
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
167
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
178
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
189
import { getUserId } from "@bitwarden/common/auth/services/account.service";
@@ -30,7 +21,6 @@ import { VaultFilter } from "../models/vault-filter.model";
3021
export class VaultFilterSectionComponent implements OnInit, OnDestroy {
3122
private destroy$ = new Subject<void>();
3223
private activeUserId$ = getUserId(this.accountService.activeAccount$);
33-
@ViewChild(PremiumBadgeComponent) private premiumBadgeComponent: PremiumBadgeComponent;
3424

3525
@Input() activeFilter: VaultFilter;
3626
@Input() section: VaultFilterSection;
@@ -100,8 +90,8 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
10090
}
10191

10292
async onFilterSelect(filterNode: TreeNode<VaultFilterType>) {
103-
if (this.premiumFeature && this.premiumBadgeComponent) {
104-
await this.premiumBadgeComponent.promptForPremium();
93+
if (this.section?.premiumOptions?.blockFilterAction) {
94+
await this.section.premiumOptions.blockFilterAction();
10595
return;
10696
}
10797

@@ -133,7 +123,7 @@ export class VaultFilterSectionComponent implements OnInit, OnDestroy {
133123
}
134124

135125
get premiumFeature() {
136-
return this.section?.premiumFeature;
126+
return this.section?.premiumOptions?.showPremiumBadge;
137127
}
138128

139129
get divider() {

apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@ export type VaultFilterSection = {
4747
component: any;
4848
};
4949
divider?: boolean;
50-
/** When true, the premium badge will show on the filter for non-premium users. */
51-
premiumFeature?: true;
50+
premiumOptions?: {
51+
/** When true, the premium badge will show on the filter for non-premium users. */
52+
showPremiumBadge?: true;
53+
/** Action to be called instead of applying the filter. */
54+
blockFilterAction?: () => Promise<void>;
55+
};
5256
};
5357

5458
export type VaultFilterList = {

apps/web/src/app/vault/individual-vault/vault.component.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@
3434
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
3535
{{ trashCleanupWarning }}
3636
</bit-callout>
37+
<bit-callout
38+
type="info"
39+
[title]="'premiumSubscriptionEnded' | i18n"
40+
*ngIf="showSubscriptionEndedMessaging$ | async"
41+
>
42+
<p>{{ "premiumSubscriptionEndedDesc" | i18n }}</p>
43+
<a routerLink="/settings/subscription/premium" bitButton buttonType="primary">{{
44+
"restartPremium" | i18n
45+
}}</a>
46+
</bit-callout>
3747
<app-vault-items
3848
#vaultItems
3949
[ciphers]="ciphers"

apps/web/src/app/vault/individual-vault/vault.component.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ import {
7979
CipherViewLikeUtils,
8080
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
8181
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
82-
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
82+
import { DialogRef, DialogService, ToastService, BannerComponent } from "@bitwarden/components";
8383
import { CipherListView } from "@bitwarden/sdk-internal";
8484
import {
8585
AddEditFolderDialogComponent,
@@ -164,6 +164,7 @@ type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;
164164
VaultItemsModule,
165165
SharedModule,
166166
OrganizationWarningsModule,
167+
BannerComponent,
167168
],
168169
providers: [
169170
RoutedVaultFilterService,
@@ -211,13 +212,6 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
211212
.pipe(map((a) => a?.id))
212213
.pipe(switchMap((id) => this.organizationService.organizations$(id)));
213214

214-
protected userCanArchive$ = this.accountService.activeAccount$.pipe(
215-
getUserId,
216-
switchMap((userId) => {
217-
return this.cipherArchiveService.userCanArchive$(userId);
218-
}),
219-
);
220-
221215
emptyState$ = combineLatest([
222216
this.currentSearchText$,
223217
this.routedVaultFilterService.filter$,
@@ -276,14 +270,28 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
276270
}),
277271
);
278272

279-
protected enforceOrgDataOwnershipPolicy$ = this.accountService.activeAccount$.pipe(
280-
getUserId,
273+
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
274+
275+
protected enforceOrgDataOwnershipPolicy$ = this.userId$.pipe(
281276
switchMap((userId) =>
282277
this.policyService.policyAppliesToUser$(PolicyType.OrganizationDataOwnership, userId),
283278
),
284279
);
285280

286-
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
281+
protected userCanArchive$ = this.userId$.pipe(
282+
switchMap((userId) => {
283+
return this.cipherArchiveService.userCanArchive$(userId);
284+
}),
285+
);
286+
287+
protected showSubscriptionEndedMessaging$ = this.userId$.pipe(
288+
switchMap((userId) =>
289+
combineLatest([
290+
this.routedVaultFilterBridgeService.activeFilter$,
291+
this.cipherArchiveService.showSubscriptionEndedMessaging$(userId),
292+
]).pipe(map(([activeFilter, showMessaging]) => activeFilter.isArchived && showMessaging)),
293+
),
294+
);
287295

288296
constructor(
289297
private syncService: SyncService,

apps/web/src/locales/en/messages.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2929,6 +2929,15 @@
29292929
}
29302930
}
29312931
},
2932+
"premiumSubscriptionEnded": {
2933+
"message": "Your Premium subscription ended"
2934+
},
2935+
"premiumSubscriptionEndedDesc": {
2936+
"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."
2937+
},
2938+
"restartPremium": {
2939+
"message": "Restart Premium"
2940+
},
29322941
"additionalStorageGb": {
29332942
"message": "Additional storage (GB)"
29342943
},

libs/common/src/vault/abstractions/cipher-archive.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ export abstract class CipherArchiveService {
1010
abstract userHasPremium$(userId: UserId): Observable<boolean>;
1111
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
1212
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
13+
abstract showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean>;
1314
}

libs/common/src/vault/services/default-cipher-archive.service.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,51 @@ describe("DefaultCipherArchiveService", () => {
151151
});
152152
});
153153

154+
describe("showSubscriptionEndedMessaging$", () => {
155+
it("returns true when user has archived ciphers but no premium", async () => {
156+
const mockCiphers: CipherListView[] = [
157+
{
158+
id: "1",
159+
archivedDate: "2024-01-15T10:30:00.000Z",
160+
type: "identity",
161+
} as unknown as CipherListView,
162+
];
163+
164+
mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers));
165+
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
166+
167+
const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId));
168+
169+
expect(result).toBe(true);
170+
});
171+
172+
it("returns false when user has archived ciphers and has premium", async () => {
173+
const mockCiphers: CipherListView[] = [
174+
{
175+
id: "1",
176+
archivedDate: "2024-01-15T10:30:00.000Z",
177+
type: "identity",
178+
} as unknown as CipherListView,
179+
];
180+
181+
mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers));
182+
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true));
183+
184+
const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId));
185+
186+
expect(result).toBe(false);
187+
});
188+
189+
it("returns false when user has no archived ciphers and no premium", async () => {
190+
mockCipherService.cipherListViews$.mockReturnValue(of([]));
191+
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
192+
193+
const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId));
194+
195+
expect(result).toBe(false);
196+
});
197+
});
198+
154199
describe("archiveWithServer", () => {
155200
const mockResponse = {
156201
data: [

libs/common/src/vault/services/default-cipher-archive.service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
7171
.pipe(shareReplay({ refCount: true, bufferSize: 1 }));
7272
}
7373

74+
/** Returns true when the user has previously archived ciphers but lost their premium membership. */
75+
showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean> {
76+
return combineLatest([this.archivedCiphers$(userId), this.userHasPremium$(userId)]).pipe(
77+
map(([archivedCiphers, hasPremium]) => archivedCiphers.length > 0 && !hasPremium),
78+
shareReplay({ refCount: true, bufferSize: 1 }),
79+
);
80+
}
81+
7482
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
7583
const request = new CipherBulkArchiveRequest(Array.isArray(ids) ? ids : [ids]);
7684
const r = await this.apiService.send("PUT", "/ciphers/archive", request, true, true);

0 commit comments

Comments
 (0)