1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-29 22:53:44 +00:00

[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
This commit is contained in:
Jason Ng
2025-12-15 18:16:04 -05:00
committed by GitHub
parent 4b93df98c8
commit d130c443b8
10 changed files with 219 additions and 89 deletions

View File

@@ -2,43 +2,53 @@
<span bitDialogTitle aria-live="polite">
{{ title }}
</span>
@if (cipherIsArchived) {
<span bitBadge bitDialogHeaderEnd> {{ "archiveNoun" | i18n }} </span>
}
<div bitDialogContent #dialogContent>
<app-cipher-view
*ngIf="showCipherView"
[cipher]="cipher"
[collections]="collections"
[isAdminConsole]="formConfig.isAdminConsole"
></app-cipher-view>
<vault-cipher-form
*ngIf="loadForm"
formId="cipherForm"
[config]="formConfig"
[submitBtn]="submitBtn"
(formReady)="onFormReady()"
(cipherSaved)="onCipherSaved($event)"
(formStatusChange$)="formStatusChanged($event)"
>
<bit-item slot="attachment-button">
<button
[disabled]="attachmentsButtonDisabled"
bit-item-content
type="button"
(click)="openAttachmentsDialog()"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
@if (showCipherView) {
<app-cipher-view
[cipher]="cipher"
[collections]="collections"
[isAdminConsole]="formConfig.isAdminConsole"
></app-cipher-view>
}
@if (loadForm) {
<vault-cipher-form
formId="cipherForm"
[config]="formConfig"
[submitBtn]="submitBtn"
(formReady)="onFormReady()"
(cipherSaved)="onCipherSaved($event)"
(formStatusChange$)="formStatusChanged($event)"
>
<bit-item slot="attachment-button">
<button
[disabled]="attachmentsButtonDisabled"
bit-item-content
type="button"
(click)="openAttachmentsDialog()"
>
<div class="tw-flex tw-items-center tw-gap-2">
{{ "attachments" | i18n }}
<app-premium-badge></app-premium-badge>
</div>
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</button>
</bit-item>
</vault-cipher-form>
}
</div>
<ng-container bitDialogFooter>
<button *ngIf="showRestore" [bitAction]="restore" bitButton buttonType="primary" type="button">
{{ "restore" | i18n }}
</button>
<ng-container *ngIf="showEdit">
@if (showRestore) {
<button [bitAction]="restore" bitButton buttonType="primary" type="button">
{{ "restore" | i18n }}
</button>
}
@if (showEdit) {
<button
bitButton
[bitAction]="switchToEdit"
@@ -48,7 +58,8 @@
>
{{ "edit" | i18n }}
</button>
</ng-container>
}
<button
bitButton
type="submit"
@@ -57,24 +68,33 @@
#submitBtn
[hidden]="showCipherView || showRestore"
>
{{ "save" | i18n }}
{{ submitButtonText$ | async }}
</button>
<button bitButton type="button" buttonType="secondary" (click)="cancel()" *ngIf="showCancel">
{{ "cancel" | i18n }}
</button>
<button bitButton type="button" buttonType="secondary" (click)="cancel()" *ngIf="showClose">
{{ "close" | i18n }}
</button>
<div class="tw-ml-auto" *ngIf="showDelete">
<button
bitIconButton="bwi-trash"
type="button"
buttonType="danger"
[label]="'delete' | i18n"
[bitAction]="delete"
[disabled]="!canDelete"
data-testid="delete-cipher-btn"
></button>
</div>
@if (showCancel) {
<button bitButton type="button" buttonType="secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
}
@if (showClose) {
<button bitButton type="button" buttonType="secondary" (click)="cancel()">
{{ "close" | i18n }}
</button>
}
@if (showDelete) {
<div class="tw-ml-auto">
<button
bitIconButton="bwi-trash"
type="button"
buttonType="danger"
[label]="'delete' | i18n"
[bitAction]="delete"
[disabled]="!canDelete"
data-testid="delete-cipher-btn"
></button>
</div>
}
</ng-container>
</bit-dialog>

View File

@@ -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();
});
});
});
});

View File

@@ -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<string> {
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);

View File

@@ -172,7 +172,7 @@
<bit-menu-divider *ngIf="showMenuDivider"></bit-menu-divider>
@if (!viewingOrgVault) {
<button bitMenuItem type="button" (click)="toggleFavorite()">
<button bitMenuItem type="button" *ngIf="showFavorite" (click)="toggleFavorite()">
<i class="bwi bwi-fw bwi-star" aria-hidden="true"></i>
{{ (cipher.favorite ? "unfavorite" : "favorite") | i18n }}
</button>

View File

@@ -253,14 +253,6 @@ export class VaultCipherRowComponent<C extends CipherViewLike> 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<C extends CipherViewLike> 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<C extends CipherViewLike> 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",

View File

@@ -31,19 +31,23 @@
></app-vault-filter>
</div>
<div class="tw-basis-3/4 tw-max-w-3/4 tw-px-2.5">
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
{{ trashCleanupWarning }}
</bit-callout>
<bit-callout
type="info"
[title]="'premiumSubscriptionEnded' | i18n"
*ngIf="showSubscriptionEndedMessaging$ | async"
>
<p>{{ "premiumSubscriptionEndedDesc" | i18n }}</p>
<a routerLink="/settings/subscription/premium" bitButton buttonType="primary">{{
"restartPremium" | i18n
}}</a>
</bit-callout>
@if (activeFilter.isDeleted) {
<bit-callout type="warning">
{{ trashCleanupWarning }}
</bit-callout>
}
@if (showSubscriptionEndedMessaging$ | async) {
<bit-callout type="default" [title]="'premiumSubscriptionEnded' | i18n">
<ng-container>
<div>
{{ "premiumSubscriptionEndedDesc" | i18n }}
</div>
<a bitLink (click)="navigateToGetPremium()"> {{ "restartPremium" | i18n }} </a>
</ng-container>
</bit-callout>
}
<app-vault-items
#vaultItems
[ciphers]="ciphers"

View File

@@ -658,6 +658,10 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
this.vaultFilterService.clearOrganizationFilter();
}
async navigateToGetPremium() {
await this.router.navigate(["/settings/subscription/premium"]);
}
async onVaultItemsEvent(event: VaultItemEvent<C>) {
this.processingEvent = true;
try {

View File

@@ -11591,6 +11591,9 @@
"unArchive": {
"message": "Unarchive"
},
"unArchiveAndSave": {
"message": "Unarchive and save"
},
"itemsInArchive": {
"message": "Items in archive"
},

View File

@@ -34,16 +34,21 @@
}
<ng-content select="[bitDialogTitle]"></ng-content>
</h2>
@if (!this.dialogRef?.disableClose) {
<button
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
bitDialogClose
[label]="'close' | i18n"
></button>
}
<div class="tw-flex tw-items-center tw-justify-center">
<ng-content select="[bitDialogHeaderEnd]"></ng-content>
@if (!this.dialogRef?.disableClose) {
<button
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
bitDialogClose
[label]="'close' | i18n"
></button>
}
</div>
</header>
<div

View File

@@ -94,6 +94,7 @@ export const Default: Story = {
<ng-container bitDialogTitle>
<span bitBadge variant="success">Foobar</span>
</ng-container>
<ng-container bitDialogContent>Dialog body text goes here.</ng-container>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button>
@@ -292,3 +293,42 @@ export const WithCards: Story = {
disableAnimations: true,
},
};
export const HeaderEnd: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-dialog
[dialogSize]="dialogSize"
[title]="title"
[subtitle]="subtitle"
[loading]="loading"
[disablePadding]="disablePadding"
[disableAnimations]="disableAnimations">
<ng-container bitDialogHeaderEnd>
<span bitBadge>Archived</span>
</ng-container>
<ng-container bitDialogContent>Dialog body text goes here.</ng-container>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button>
<button type="button" bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
<button
type="button"
[disabled]="loading"
class="tw-ms-auto"
bitIconButton="bwi-trash"
buttonType="danger"
size="default"
label="Delete"></button>
</ng-container>
</bit-dialog>
`,
}),
args: {
dialogSize: "small",
title: "Very Long Title That Should Be Truncated After Two Lines To Test Header End Slot",
subtitle: "Subtitle",
},
};