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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11591,6 +11591,9 @@
|
||||
"unArchive": {
|
||||
"message": "Unarchive"
|
||||
},
|
||||
"unArchiveAndSave": {
|
||||
"message": "Unarchive and save"
|
||||
},
|
||||
"itemsInArchive": {
|
||||
"message": "Items in archive"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user