mirror of
https://github.com/bitwarden/browser
synced 2026-02-18 18:33:50 +00:00
[PM-26516] Archive Vault Updates Non Premium (#18068)
* add callout to vault-items for non premium users, add upgrade premium flow * add archive badge to item details only for desktop * update desktop edit item save for unarchive * updated success toast for edited archive item non premium
This commit is contained in:
committed by
jaasen-livefront
parent
5acc0b4378
commit
f7d358444f
@@ -585,6 +585,9 @@
|
||||
"upgradeToUseArchive": {
|
||||
"message": "A premium membership is required to use Archive."
|
||||
},
|
||||
"itemRestored": {
|
||||
"message": "Item has been restored"
|
||||
},
|
||||
"edit": {
|
||||
"message": "Edit"
|
||||
},
|
||||
|
||||
@@ -4306,6 +4306,9 @@
|
||||
"unArchive": {
|
||||
"message": "Unarchive"
|
||||
},
|
||||
"archived": {
|
||||
"message": "Archived"
|
||||
},
|
||||
"itemsInArchive": {
|
||||
"message": "Items in archive"
|
||||
},
|
||||
@@ -4327,6 +4330,21 @@
|
||||
"archiveItemConfirmDesc": {
|
||||
"message": "Archived items are excluded from general search results and autofill suggestions. Are you sure you want to archive this item?"
|
||||
},
|
||||
"unArchiveAndSave": {
|
||||
"message": "Unarchive and save"
|
||||
},
|
||||
"restartPremium": {
|
||||
"message": "Restart Premium"
|
||||
},
|
||||
"premiumSubscriptionEnded": {
|
||||
"message": "Your Premium subscription ended"
|
||||
},
|
||||
"premiumSubscriptionEndedDesc": {
|
||||
"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."
|
||||
},
|
||||
"itemRestored": {
|
||||
"message": "Item has been restored"
|
||||
},
|
||||
"zipPostalCodeLabel": {
|
||||
"message": "ZIP / Postal code"
|
||||
},
|
||||
@@ -4475,7 +4493,7 @@
|
||||
"placeholders": {
|
||||
"organization": {
|
||||
"content": "$1",
|
||||
"example": "My Org Name"
|
||||
"example": "My Org Name"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4484,7 +4502,7 @@
|
||||
"placeholders": {
|
||||
"organization": {
|
||||
"content": "$1",
|
||||
"example": "My Org Name"
|
||||
"example": "My Org Name"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
[hidden]="action === 'view'"
|
||||
bitButton
|
||||
class="primary"
|
||||
appA11yTitle="{{ 'save' | i18n }}"
|
||||
appA11yTitle="{{ submitButtonText() }}"
|
||||
>
|
||||
{{ "save" | i18n }}
|
||||
{{ submitButtonText() }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ViewChild,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
input,
|
||||
} from "@angular/core";
|
||||
import { combineLatest, firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
@@ -67,6 +68,8 @@ export class ItemFooterComponent implements OnInit, OnChanges {
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@ViewChild("submitBtn", { static: false }) submitBtn: ButtonComponent | null = null;
|
||||
|
||||
readonly submitButtonText = input<string>(this.i18nService.t("save"));
|
||||
|
||||
activeUserId: UserId | null = null;
|
||||
passwordReprompted: boolean = false;
|
||||
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
</div>
|
||||
<ng-container *ngIf="loaded">
|
||||
@if (showPremiumCallout()) {
|
||||
<div class="tw-m-4">
|
||||
<bit-callout type="default" [title]="'premiumSubscriptionEnded' | i18n">
|
||||
<ng-container>
|
||||
<div>
|
||||
{{ "premiumSubscriptionEndedDesc" | i18n }}
|
||||
</div>
|
||||
<a bitLink href="#" appStopClick (click)="navigateToGetPremium()">
|
||||
{{ "restartPremium" | i18n }}
|
||||
</a>
|
||||
</ng-container>
|
||||
</bit-callout>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="content">
|
||||
<cdk-virtual-scroll-viewport
|
||||
itemSize="42"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ScrollingModule } from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { Component, input } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { distinctUntilChanged, debounceTime } from "rxjs";
|
||||
|
||||
@@ -9,7 +9,9 @@ import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angul
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { uuidAsString } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
|
||||
import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service";
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
CipherViewLike,
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
import { MenuModule } from "@bitwarden/components";
|
||||
import { CalloutComponent, MenuModule } from "@bitwarden/components";
|
||||
|
||||
import { SearchBarService } from "../../../app/layout/search/search-bar.service";
|
||||
|
||||
@@ -26,10 +28,14 @@ import { SearchBarService } from "../../../app/layout/search/search-bar.service"
|
||||
@Component({
|
||||
selector: "app-vault-items-v2",
|
||||
templateUrl: "vault-items-v2.component.html",
|
||||
imports: [MenuModule, CommonModule, JslibModule, ScrollingModule],
|
||||
imports: [MenuModule, CommonModule, JslibModule, ScrollingModule, CalloutComponent],
|
||||
})
|
||||
export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultItemsComponent<C> {
|
||||
readonly showPremiumCallout = input<boolean>(false);
|
||||
readonly organizationId = input<OrganizationId | undefined>(undefined);
|
||||
|
||||
protected CipherViewLikeUtils = CipherViewLikeUtils;
|
||||
|
||||
constructor(
|
||||
searchService: SearchService,
|
||||
private readonly searchBarService: SearchBarService,
|
||||
@@ -37,6 +43,7 @@ export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultIt
|
||||
accountService: AccountService,
|
||||
restrictedItemTypesService: RestrictedItemTypesService,
|
||||
configService: ConfigService,
|
||||
private premiumUpgradePromptService: PremiumUpgradePromptService,
|
||||
) {
|
||||
super(searchService, cipherService, accountService, restrictedItemTypesService, configService);
|
||||
|
||||
@@ -47,6 +54,10 @@ export class VaultItemsV2Component<C extends CipherViewLike> extends BaseVaultIt
|
||||
});
|
||||
}
|
||||
|
||||
async navigateToGetPremium() {
|
||||
await this.premiumUpgradePromptService.promptForPremium(this.organizationId());
|
||||
}
|
||||
|
||||
trackByFn(index: number, c: C): string {
|
||||
return uuidAsString(c.id!);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,15 @@
|
||||
(onCipherClicked)="viewCipher($event)"
|
||||
(onCipherRightClicked)="viewCipherMenu($event)"
|
||||
(onAddCipher)="addCipher($event)"
|
||||
[showPremiumCallout]="showPremiumCallout$ | async"
|
||||
[organizationId]="organizationId"
|
||||
>
|
||||
</app-vault-items-v2>
|
||||
<div class="details" *ngIf="!!action">
|
||||
<app-vault-item-footer
|
||||
id="footer"
|
||||
#footer
|
||||
[cipher]="cipher"
|
||||
[cipher]="cipher()"
|
||||
[action]="action"
|
||||
(onEdit)="editCipher($event)"
|
||||
(onRestore)="restoreCipher()"
|
||||
@@ -21,11 +23,16 @@
|
||||
(onCancel)="cancelCipher($event)"
|
||||
(onArchiveToggle)="refreshCurrentCipher()"
|
||||
[masterPasswordAlreadyPrompted]="cipherRepromptId === cipherId"
|
||||
[submitButtonText]="submitButtonText()"
|
||||
></app-vault-item-footer>
|
||||
<div class="content">
|
||||
<div class="inner-content">
|
||||
<div class="box">
|
||||
<app-cipher-view *ngIf="action === 'view'" [cipher]="cipher" [collections]="collections">
|
||||
<app-cipher-view
|
||||
*ngIf="action === 'view'"
|
||||
[cipher]="cipher()"
|
||||
[collections]="collections"
|
||||
>
|
||||
</app-cipher-view>
|
||||
<vault-cipher-form
|
||||
#vaultForm
|
||||
|
||||
@@ -2,14 +2,25 @@ import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
computed,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
ViewChild,
|
||||
ViewContainerRef,
|
||||
} from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom, Subject, takeUntil, switchMap, lastValueFrom, Observable } from "rxjs";
|
||||
import {
|
||||
firstValueFrom,
|
||||
Subject,
|
||||
takeUntil,
|
||||
switchMap,
|
||||
lastValueFrom,
|
||||
Observable,
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
} from "rxjs";
|
||||
import { filter, map, take } from "rxjs/operators";
|
||||
|
||||
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
|
||||
@@ -163,7 +174,7 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
type: CipherType | null = null;
|
||||
folderId: string | null = null;
|
||||
collectionId: string | null = null;
|
||||
organizationId: string | null = null;
|
||||
organizationId: OrganizationId | null = null;
|
||||
myVaultOnly = false;
|
||||
addType: CipherType | undefined = undefined;
|
||||
addOrganizationId: string | null = null;
|
||||
@@ -172,11 +183,25 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
deleted = false;
|
||||
userHasPremiumAccess = false;
|
||||
activeFilter: VaultFilter = new VaultFilter();
|
||||
private activeFilterSubject = new BehaviorSubject<VaultFilter>(new VaultFilter());
|
||||
private activeFilter$ = this.activeFilterSubject.asObservable();
|
||||
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
|
||||
showPremiumCallout$ = this.userId$.pipe(
|
||||
switchMap((userId) =>
|
||||
combineLatest([
|
||||
this.activeFilter$,
|
||||
this.cipherArchiveService.showSubscriptionEndedMessaging$(userId),
|
||||
]).pipe(
|
||||
map(([activeFilter, showMessaging]) => activeFilter.status === "archive" && showMessaging),
|
||||
),
|
||||
),
|
||||
);
|
||||
activeUserId: UserId | null = null;
|
||||
cipherRepromptId: string | null = null;
|
||||
cipher: CipherView | null = new CipherView();
|
||||
readonly cipher = signal<CipherView | null>(null);
|
||||
collections: CollectionView[] | null = null;
|
||||
config: CipherFormConfig | null = null;
|
||||
readonly userHasPremium = signal<boolean>(false);
|
||||
|
||||
/** Tracks the disabled status of the edit cipher form */
|
||||
protected formDisabled: boolean = false;
|
||||
@@ -187,12 +212,13 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
switchMap((id) => this.organizationService.organizations$(id)),
|
||||
);
|
||||
|
||||
protected canAccessAttachments$ = this.accountService.activeAccount$.pipe(
|
||||
filter((account): account is Account => !!account),
|
||||
switchMap((account) =>
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
|
||||
),
|
||||
);
|
||||
protected readonly submitButtonText = computed(() => {
|
||||
return this.cipher()?.isArchived &&
|
||||
!this.userHasPremium() &&
|
||||
this.cipherArchiveService.hasArchiveFlagEnabled$
|
||||
? this.i18nService.t("unArchiveAndSave")
|
||||
: this.i18nService.t("save");
|
||||
});
|
||||
|
||||
private componentIsDestroyed$ = new Subject<boolean>();
|
||||
private allOrganizations: Organization[] = [];
|
||||
@@ -241,6 +267,7 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
)
|
||||
.subscribe((canAccessPremium: boolean) => {
|
||||
this.userHasPremiumAccess = canAccessPremium;
|
||||
this.userHasPremium.set(canAccessPremium);
|
||||
});
|
||||
|
||||
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
|
||||
@@ -288,30 +315,40 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
this.showingModal = false;
|
||||
break;
|
||||
case "copyUsername": {
|
||||
if (this.cipher?.login?.username) {
|
||||
this.copyValue(this.cipher, this.cipher?.login?.username, "username", "Username");
|
||||
if (this.cipher()?.login?.username) {
|
||||
this.copyValue(
|
||||
this.cipher(),
|
||||
this.cipher()?.login?.username,
|
||||
"username",
|
||||
"Username",
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "copyPassword": {
|
||||
if (this.cipher?.login?.password && this.cipher.viewPassword) {
|
||||
this.copyValue(this.cipher, this.cipher.login.password, "password", "Password");
|
||||
if (this.cipher()?.login?.password && this.cipher().viewPassword) {
|
||||
this.copyValue(
|
||||
this.cipher(),
|
||||
this.cipher().login.password,
|
||||
"password",
|
||||
"Password",
|
||||
);
|
||||
await this.eventCollectionService
|
||||
.collect(EventType.Cipher_ClientCopiedPassword, this.cipher.id)
|
||||
.collect(EventType.Cipher_ClientCopiedPassword, this.cipher().id)
|
||||
.catch(() => {});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "copyTotp": {
|
||||
if (
|
||||
this.cipher?.login?.hasTotp &&
|
||||
(this.cipher.organizationUseTotp || this.userHasPremiumAccess)
|
||||
this.cipher()?.login?.hasTotp &&
|
||||
(this.cipher()?.organizationUseTotp || this.userHasPremiumAccess)
|
||||
) {
|
||||
const value = await firstValueFrom(
|
||||
this.totpService.getCode$(this.cipher.login.totp),
|
||||
this.totpService.getCode$(this.cipher()?.login.totp),
|
||||
).catch((): any => null);
|
||||
if (value) {
|
||||
this.copyValue(this.cipher, value.code, "verificationCodeTotp", "TOTP");
|
||||
this.copyValue(this.cipher(), value.code, "verificationCodeTotp", "TOTP");
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -416,6 +453,7 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
selectedOrganizationId: params.selectedOrganizationId,
|
||||
myVaultOnly: params.myVaultOnly ?? false,
|
||||
});
|
||||
this.activeFilterSubject.next(this.activeFilter);
|
||||
if (this.vaultItemsComponent) {
|
||||
await this.vaultItemsComponent.reload(this.activeFilter.buildFilter()).catch(() => {});
|
||||
}
|
||||
@@ -440,7 +478,7 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
return;
|
||||
}
|
||||
this.cipherId = cipher.id;
|
||||
this.cipher = cipher;
|
||||
this.cipher.set(cipher);
|
||||
this.collections =
|
||||
this.vaultFilterComponent?.collections?.fullList.filter((c) =>
|
||||
cipher.collectionIds.includes(c.id),
|
||||
@@ -679,7 +717,7 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
return;
|
||||
}
|
||||
this.cipherId = cipher.id;
|
||||
this.cipher = cipher;
|
||||
this.cipher.set(cipher);
|
||||
await this.buildFormConfig("edit");
|
||||
if (!cipher.edit && this.config) {
|
||||
this.config.mode = "partial-edit";
|
||||
@@ -693,7 +731,7 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
return;
|
||||
}
|
||||
this.cipherId = cipher.id;
|
||||
this.cipher = cipher;
|
||||
this.cipher.set(cipher);
|
||||
await this.buildFormConfig("clone");
|
||||
this.action = "clone";
|
||||
await this.go().catch(() => {});
|
||||
@@ -742,7 +780,7 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
return;
|
||||
}
|
||||
this.addType = type || this.activeFilter.cipherType;
|
||||
this.cipher = new CipherView();
|
||||
this.cipher.set(new CipherView());
|
||||
this.cipherId = null;
|
||||
await this.buildFormConfig("add");
|
||||
this.action = "add";
|
||||
@@ -774,14 +812,14 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
);
|
||||
|
||||
this.cipherId = cipher.id;
|
||||
this.cipher = cipher;
|
||||
this.cipher.set(cipher);
|
||||
await this.go().catch(() => {});
|
||||
await this.vaultItemsComponent?.refresh().catch(() => {});
|
||||
}
|
||||
|
||||
async deleteCipher() {
|
||||
this.cipherId = null;
|
||||
this.cipher = null;
|
||||
this.cipher.set(null);
|
||||
this.action = null;
|
||||
await this.go().catch(() => {});
|
||||
await this.vaultItemsComponent?.refresh().catch(() => {});
|
||||
@@ -796,7 +834,7 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
|
||||
async cancelCipher(cipher: CipherView) {
|
||||
this.cipherId = cipher.id;
|
||||
this.cipher = cipher;
|
||||
this.cipher.set(cipher);
|
||||
this.action = this.cipherId ? "view" : null;
|
||||
await this.go().catch(() => {});
|
||||
}
|
||||
@@ -806,6 +844,7 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)),
|
||||
);
|
||||
this.activeFilter = vaultFilter;
|
||||
this.activeFilterSubject.next(vaultFilter);
|
||||
await this.vaultItemsComponent
|
||||
?.reload(
|
||||
this.activeFilter.buildFilter(),
|
||||
@@ -887,14 +926,16 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
|
||||
/** Refresh the current cipher object */
|
||||
protected async refreshCurrentCipher() {
|
||||
if (!this.cipher) {
|
||||
if (!this.cipher()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cipher = await firstValueFrom(
|
||||
this.cipherService.cipherViews$(this.activeUserId!).pipe(
|
||||
filter((c) => !!c),
|
||||
map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null),
|
||||
this.cipher.set(
|
||||
await firstValueFrom(
|
||||
this.cipherService.cipherViews$(this.activeUserId!).pipe(
|
||||
filter((c) => !!c),
|
||||
map((ciphers) => ciphers.find((c) => c.id === this.cipherId) ?? null),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{ title }}
|
||||
</span>
|
||||
@if (cipherIsArchived) {
|
||||
<span bitBadge bitDialogHeaderEnd> {{ "archiveNoun" | i18n }} </span>
|
||||
<span bitBadge bitDialogHeaderEnd> {{ "archived" | i18n }} </span>
|
||||
}
|
||||
|
||||
<div bitDialogContent #dialogContent>
|
||||
|
||||
@@ -3146,6 +3146,9 @@
|
||||
"premiumSubscriptionEndedDesc": {
|
||||
"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."
|
||||
},
|
||||
"itemRestored": {
|
||||
"message": "Item has been restored"
|
||||
},
|
||||
"restartPremium": {
|
||||
"message": "Restart Premium"
|
||||
},
|
||||
@@ -11613,6 +11616,9 @@
|
||||
"unArchive": {
|
||||
"message": "Unarchive"
|
||||
},
|
||||
"archived": {
|
||||
"message": "Archived"
|
||||
},
|
||||
"unArchiveAndSave": {
|
||||
"message": "Unarchive and save"
|
||||
},
|
||||
|
||||
@@ -165,6 +165,7 @@ describe("DefaultCipherArchiveService", () => {
|
||||
|
||||
mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers));
|
||||
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||
featureFlag.next(true);
|
||||
|
||||
const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId));
|
||||
|
||||
|
||||
@@ -71,8 +71,15 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
|
||||
/** Returns true when the user has previously archived ciphers but lost their premium membership. */
|
||||
showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean> {
|
||||
return combineLatest([this.archivedCiphers$(userId), this.userHasPremium$(userId)]).pipe(
|
||||
map(([archivedCiphers, hasPremium]) => archivedCiphers.length > 0 && !hasPremium),
|
||||
return combineLatest([
|
||||
this.archivedCiphers$(userId),
|
||||
this.userHasPremium$(userId),
|
||||
this.hasArchiveFlagEnabled$,
|
||||
]).pipe(
|
||||
map(
|
||||
([archivedCiphers, hasPremium, flagEnabled]) =>
|
||||
flagEnabled && archivedCiphers.length > 0 && !hasPremium,
|
||||
),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -358,6 +358,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
let successToast: string = "editedItem";
|
||||
if (this.cipherForm.invalid) {
|
||||
this.cipherForm.markAllAsTouched();
|
||||
|
||||
@@ -392,6 +393,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
// If the item is archived but user has lost archive permissions, unarchive the item.
|
||||
if (!userCanArchive && this.updatedCipherView.archivedDate) {
|
||||
this.updatedCipherView.archivedDate = null;
|
||||
successToast = "itemRestored";
|
||||
}
|
||||
|
||||
const savedCipher = await this.addEditFormService.saveCipher(
|
||||
@@ -407,7 +409,7 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci
|
||||
title: null,
|
||||
message: this.i18nService.t(
|
||||
this.config.mode === "edit" || this.config.mode === "partial-edit"
|
||||
? "editedItem"
|
||||
? successToast
|
||||
: "addedItem",
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<section [formGroup]="itemDetailsForm" class="tw-mb-5 bit-compact:tw-mb-4">
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "itemDetails" | i18n }}</h2>
|
||||
@if (showArchiveBadge()) {
|
||||
<span bitBadge> {{ "archived" | i18n }} </span>
|
||||
}
|
||||
<button
|
||||
*ngIf="!config.hideIndividualVaultFields"
|
||||
slot="end"
|
||||
|
||||
@@ -8,13 +8,16 @@ import { BehaviorSubject, of } from "rxjs";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionType, CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { ClientType } from "@bitwarden/client-type";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { SelectComponent } from "@bitwarden/components";
|
||||
@@ -62,6 +65,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockPolicyService: MockProxy<PolicyService>;
|
||||
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let mockCipherArchiveService: MockProxy<CipherArchiveService>;
|
||||
|
||||
const activeAccount$ = new BehaviorSubject<{ email: string }>({ email: "test@example.com" });
|
||||
const getInitialCipherView = jest.fn<CipherView | null, []>(() => null);
|
||||
@@ -90,6 +95,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
mockPolicyService = mock<PolicyService>();
|
||||
mockPolicyService.policiesByType$.mockReturnValue(of([]));
|
||||
mockPlatformUtilsService = mock<PlatformUtilsService>();
|
||||
mockCipherArchiveService = mock<CipherArchiveService>();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ItemDetailsSectionComponent, CommonModule, ReactiveFormsModule],
|
||||
@@ -99,6 +106,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
{ provide: AccountService, useValue: { activeAccount$ } },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: PolicyService, useValue: mockPolicyService },
|
||||
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||
{ provide: CipherArchiveService, useValue: mockCipherArchiveService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -209,9 +218,10 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
describe("allowOwnershipChange", () => {
|
||||
it("should not allow ownership change if in edit mode and the cipher is owned by an organization", () => {
|
||||
component.config.mode = "edit";
|
||||
component.originalCipherView = {
|
||||
fixture.componentRef.setInput("originalCipherView", {
|
||||
organizationId: "org1",
|
||||
} as CipherView;
|
||||
} as CipherView);
|
||||
|
||||
expect(component.allowOwnershipChange).toBe(false);
|
||||
});
|
||||
|
||||
@@ -251,7 +261,7 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
it("should show organization data ownership when the configuration allows", () => {
|
||||
component.config.mode = "edit";
|
||||
component.config.organizationDataOwnershipDisabled = true;
|
||||
component.originalCipherView = {} as CipherView;
|
||||
fixture.componentRef.setInput("originalCipherView", {} as CipherView);
|
||||
component.config.organizations = [{ id: "134-433-22" } as Organization];
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -265,7 +275,7 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
it("should show organization data ownership when the control is disabled", async () => {
|
||||
component.config.mode = "edit";
|
||||
component.config.organizationDataOwnershipDisabled = false;
|
||||
component.originalCipherView = {} as CipherView;
|
||||
fixture.componentRef.setInput("originalCipherView", {} as CipherView);
|
||||
component.config.organizations = [{ id: "134-433-22" } as Organization];
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
@@ -360,18 +370,20 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
});
|
||||
|
||||
it("should select the first organization if organization data ownership is enabled", async () => {
|
||||
component.config.organizationDataOwnershipDisabled = false;
|
||||
component.config.organizations = [
|
||||
{ id: "org1", name: "org1" } as Organization,
|
||||
{ id: "org2", name: "org2" } as Organization,
|
||||
];
|
||||
component.originalCipherView = {
|
||||
const updatedCipher = {
|
||||
name: "cipher1",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
collectionIds: [],
|
||||
favorite: false,
|
||||
} as CipherView;
|
||||
component.config.organizationDataOwnershipDisabled = false;
|
||||
component.config.organizations = [
|
||||
{ id: "org1", name: "org1" } as Organization,
|
||||
{ id: "org2", name: "org2" } as Organization,
|
||||
];
|
||||
|
||||
fixture.componentRef.setInput("originalCipherView", updatedCipher);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
@@ -469,20 +481,17 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
|
||||
it("should show readonly hint if readonly collections are present", async () => {
|
||||
component.config.mode = "edit";
|
||||
getInitialCipherView.mockReturnValueOnce({
|
||||
name: "cipher1",
|
||||
organizationId: "org1",
|
||||
folderId: "folder1",
|
||||
collectionIds: ["col1", "col2", "col3"],
|
||||
favorite: true,
|
||||
} as CipherView);
|
||||
component.originalCipherView = {
|
||||
const updatedCipher = {
|
||||
name: "cipher1",
|
||||
organizationId: "org1",
|
||||
folderId: "folder1",
|
||||
collectionIds: ["col1", "col2", "col3"],
|
||||
favorite: true,
|
||||
} as CipherView;
|
||||
getInitialCipherView.mockReturnValueOnce(updatedCipher);
|
||||
|
||||
fixture.componentRef.setInput("originalCipherView", updatedCipher);
|
||||
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
component.config.collections = [
|
||||
createMockCollection("col1", "Collection 1", "org1", true, false) as CollectionView,
|
||||
@@ -539,13 +548,16 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
i < 4 ? CollectionTypes.SharedCollection : CollectionTypes.DefaultUserCollection,
|
||||
) as CollectionView,
|
||||
);
|
||||
component.originalCipherView = {
|
||||
const updatedCipher = {
|
||||
name: "cipher1",
|
||||
organizationId: "org1",
|
||||
folderId: "folder1",
|
||||
collectionIds: ["col2", "col3"],
|
||||
favorite: true,
|
||||
} as CipherView;
|
||||
|
||||
fixture.componentRef.setInput("originalCipherView", updatedCipher);
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
@@ -567,7 +579,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
createMockCollection("col2", "Collection 2", "org1", false, true) as CollectionView,
|
||||
createMockCollection("col3", "Collection 3", "org1", true, false) as CollectionView,
|
||||
];
|
||||
component.originalCipherView = {
|
||||
|
||||
const currentCipher = {
|
||||
name: "cipher1",
|
||||
organizationId: "org1",
|
||||
folderId: "folder1",
|
||||
@@ -575,7 +588,9 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
favorite: true,
|
||||
} as CipherView;
|
||||
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||
fixture.componentRef.setInput("originalCipherView", currentCipher);
|
||||
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView());
|
||||
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
});
|
||||
@@ -604,7 +619,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
{ id: "org2", name: "org2" } as Organization,
|
||||
{ id: "org1", name: "org1" } as Organization,
|
||||
];
|
||||
component.originalCipherView = {} as CipherView;
|
||||
|
||||
fixture.componentRef.setInput("originalCipherView", {} as CipherView);
|
||||
|
||||
await component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
@@ -684,13 +700,16 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
beforeEach(() => {
|
||||
component.config.mode = "edit";
|
||||
component.config.originalCipher = new Cipher();
|
||||
component.originalCipherView = {
|
||||
|
||||
const updatedCipher = {
|
||||
name: "cipher1",
|
||||
organizationId: null,
|
||||
folderId: "folder1",
|
||||
collectionIds: ["col1", "col2", "col3"],
|
||||
favorite: true,
|
||||
} as unknown as CipherView;
|
||||
|
||||
fixture.componentRef.setInput("originalCipherView", updatedCipher);
|
||||
});
|
||||
|
||||
describe("when personal ownership is not allowed", () => {
|
||||
@@ -701,7 +720,7 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
|
||||
describe("cipher does not belong to an organization", () => {
|
||||
beforeEach(() => {
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView!);
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView()!);
|
||||
});
|
||||
|
||||
it("enables organizationId", async () => {
|
||||
@@ -720,8 +739,11 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
|
||||
describe("cipher belongs to an organization", () => {
|
||||
beforeEach(() => {
|
||||
component.originalCipherView.organizationId = "org-id";
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||
fixture.componentRef.setInput("originalCipherView", {
|
||||
...component.originalCipherView(),
|
||||
organizationId: "org-id",
|
||||
} as CipherView);
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView());
|
||||
});
|
||||
|
||||
it("enables the rest of the form", async () => {
|
||||
@@ -734,8 +756,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
|
||||
describe("setFormState behavior with null/undefined", () => {
|
||||
it("calls disableFormFields when organizationId value is null", async () => {
|
||||
component.originalCipherView.organizationId = null as any;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||
component.originalCipherView().organizationId = null as any;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView());
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
@@ -743,8 +765,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
});
|
||||
|
||||
it("calls disableFormFields when organizationId value is undefined", async () => {
|
||||
component.originalCipherView.organizationId = undefined;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||
component.originalCipherView().organizationId = undefined;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView());
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
@@ -752,8 +774,8 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
});
|
||||
|
||||
it("calls enableFormFields when organizationId has a string value", async () => {
|
||||
component.originalCipherView.organizationId = "org-id" as any;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView);
|
||||
component.originalCipherView().organizationId = "org-id" as any;
|
||||
getInitialCipherView.mockReturnValue(component.originalCipherView());
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
@@ -765,11 +787,11 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
describe("when an ownership change is not allowed", () => {
|
||||
beforeEach(() => {
|
||||
component.config.organizationDataOwnershipDisabled = true; // allow personal ownership
|
||||
component.originalCipherView!.organizationId = undefined;
|
||||
component.originalCipherView()!.organizationId = undefined;
|
||||
});
|
||||
|
||||
it("disables organizationId when the cipher is owned by an organization", async () => {
|
||||
component.originalCipherView!.organizationId = "orgId";
|
||||
component.originalCipherView()!.organizationId = "orgId";
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
@@ -785,4 +807,28 @@ describe("ItemDetailsSectionComponent", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("showArchiveBadge", () => {
|
||||
it("should set showArchiveBadge to true when cipher is archived and client is Desktop", async () => {
|
||||
component.config.organizations = [{ id: "org1" } as Organization];
|
||||
|
||||
const archivedCipher = {
|
||||
name: "archived cipher",
|
||||
organizationId: null,
|
||||
folderId: null,
|
||||
collectionIds: [],
|
||||
favorite: false,
|
||||
isArchived: true,
|
||||
} as unknown as CipherView;
|
||||
|
||||
fixture.componentRef.setInput("originalCipherView", archivedCipher);
|
||||
|
||||
getInitialCipherView.mockReturnValueOnce(archivedCipher);
|
||||
mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Desktop);
|
||||
|
||||
await component.ngOnInit();
|
||||
|
||||
expect(component["showArchiveBadge"]()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, DestroyRef, Input, OnInit } from "@angular/core";
|
||||
import { Component, computed, DestroyRef, input, Input, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs";
|
||||
@@ -10,16 +10,20 @@ import { concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ClientType } from "@bitwarden/client-type";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { OrganizationUserType, PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
BadgeComponent,
|
||||
CardComponent,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
@@ -50,6 +54,7 @@ import { CipherFormContainer } from "../../cipher-form-container";
|
||||
IconButtonModule,
|
||||
JslibModule,
|
||||
CommonModule,
|
||||
BadgeComponent,
|
||||
],
|
||||
})
|
||||
export class ItemDetailsSectionComponent implements OnInit {
|
||||
@@ -61,6 +66,14 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
favorite: [false],
|
||||
});
|
||||
|
||||
protected readonly showArchiveBadge = computed(() => {
|
||||
return (
|
||||
this.cipherArchiveService.hasArchiveFlagEnabled$ &&
|
||||
this.originalCipherView()?.isArchived &&
|
||||
this.platformUtilsService.getClientType() === ClientType.Desktop
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Collection options available for the selected organization.
|
||||
* @protected
|
||||
@@ -89,10 +102,7 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
@Input({ required: true })
|
||||
config: CipherFormConfig;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input()
|
||||
originalCipherView: CipherView;
|
||||
readonly originalCipherView = input<CipherView>();
|
||||
|
||||
get readOnlyCollectionsNames(): string[] {
|
||||
return this.readOnlyCollections.map((c) => c.name);
|
||||
@@ -140,6 +150,8 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
private destroyRef: DestroyRef,
|
||||
private accountService: AccountService,
|
||||
private policyService: PolicyService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private cipherArchiveService: CipherArchiveService,
|
||||
) {
|
||||
this.cipherFormContainer.registerChildForm("itemDetails", this.itemDetailsForm);
|
||||
this.itemDetailsForm.valueChanges
|
||||
@@ -175,7 +187,7 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
|
||||
get allowOwnershipChange() {
|
||||
// Do not allow ownership change in edit mode and the cipher is owned by an organization
|
||||
if (this.config.mode === "edit" && this.originalCipherView?.organizationId != null) {
|
||||
if (this.config.mode === "edit" && this.originalCipherView()?.organizationId != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -349,7 +361,7 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
(c) =>
|
||||
c.organizationId === orgId &&
|
||||
c.readOnly &&
|
||||
this.originalCipherView.collectionIds.includes(c.id as CollectionId),
|
||||
this.originalCipherView().collectionIds.includes(c.id as CollectionId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -406,8 +418,8 @@ export class ItemDetailsSectionComponent implements OnInit {
|
||||
* Note: `.every` will return true for an empty array
|
||||
*/
|
||||
const cipherIsOnlyInOrgCollections =
|
||||
(this.originalCipherView?.collectionIds ?? []).length > 0 &&
|
||||
this.originalCipherView.collectionIds.every(
|
||||
(this.originalCipherView()?.collectionIds ?? []).length > 0 &&
|
||||
this.originalCipherView().collectionIds.every(
|
||||
(cId) =>
|
||||
this.collections.find((c) => c.id === cId)?.type === CollectionTypes.SharedCollection,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="tw-mb-5 bit-compact:tw-mb-4">
|
||||
<bit-card>
|
||||
<div
|
||||
class="tw-flex tw-place-items-center"
|
||||
class="tw-flex tw-place-items-center tw-w-full"
|
||||
[ngClass]="{
|
||||
'tw-mb-2': allItems.length > 0,
|
||||
}"
|
||||
@@ -10,9 +10,16 @@
|
||||
<div class="tw-flex tw-items-center tw-justify-center" style="width: 40px; height: 40px">
|
||||
<app-vault-icon [cipher]="cipher()" [coloredIcon]="true"></app-vault-icon>
|
||||
</div>
|
||||
<h2 bitTypography="h4" class="tw-ml-2 tw-mt-2 tw-select-auto" data-testid="item-name">
|
||||
<h2
|
||||
bitTypography="h4"
|
||||
class="tw-ml-2 tw-mt-2 tw-select-auto tw-flex-1"
|
||||
data-testid="item-name"
|
||||
>
|
||||
{{ cipher().name }}
|
||||
</h2>
|
||||
@if (showArchiveBadge()) {
|
||||
<span bitBadge> {{ "archived" | i18n }} </span>
|
||||
}
|
||||
</div>
|
||||
<ng-container>
|
||||
<div class="tw-flex tw-flex-col tw-mt-2 md:tw-flex-row md:tw-flex-wrap">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ComponentRef } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
@@ -21,6 +22,7 @@ describe("ItemDetailsV2Component", () => {
|
||||
let component: ItemDetailsV2Component;
|
||||
let fixture: ComponentFixture<ItemDetailsV2Component>;
|
||||
let componentRef: ComponentRef<ItemDetailsV2Component>;
|
||||
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
|
||||
const cipher = {
|
||||
id: "cipher1",
|
||||
@@ -51,6 +53,8 @@ describe("ItemDetailsV2Component", () => {
|
||||
} as FolderView;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockPlatformUtilsService = mock<PlatformUtilsService>();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ItemDetailsV2Component],
|
||||
providers: [
|
||||
@@ -61,6 +65,7 @@ describe("ItemDetailsV2Component", () => {
|
||||
useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) },
|
||||
},
|
||||
{ provide: DomainSettingsService, useValue: { showFavicons$: of(true) } },
|
||||
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
@@ -98,4 +103,31 @@ describe("ItemDetailsV2Component", () => {
|
||||
const owner = fixture.debugElement.query(By.css('[data-testid="owner"]'));
|
||||
expect(owner).toBeNull();
|
||||
});
|
||||
|
||||
it("should show archive badge when cipher is archived and client is Desktop", () => {
|
||||
jest.spyOn(mockPlatformUtilsService, "getClientType").mockReturnValue(ClientType.Desktop);
|
||||
|
||||
const archivedCipher = { ...cipher, isArchived: true };
|
||||
componentRef.setInput("cipher", archivedCipher);
|
||||
|
||||
expect((component as any).showArchiveBadge()).toBe(true);
|
||||
});
|
||||
|
||||
it("should not show archive badge when cipher is not archived", () => {
|
||||
jest.spyOn(mockPlatformUtilsService, "getClientType").mockReturnValue(ClientType.Desktop);
|
||||
|
||||
const unarchivedCipher = { ...cipher, isArchived: false };
|
||||
componentRef.setInput("cipher", unarchivedCipher);
|
||||
|
||||
expect((component as any).showArchiveBadge()).toBe(false);
|
||||
});
|
||||
|
||||
it("should not show archive badge when client is not Desktop", () => {
|
||||
jest.spyOn(mockPlatformUtilsService, "getClientType").mockReturnValue(ClientType.Web);
|
||||
|
||||
const archivedCipher = { ...cipher, isArchived: true };
|
||||
componentRef.setInput("cipher", archivedCipher);
|
||||
|
||||
expect((component as any).showArchiveBadge()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,11 +9,14 @@ import { fromEvent, map, startWith } from "rxjs";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionTypes, CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ClientType } from "@bitwarden/client-type";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonLinkDirective,
|
||||
CardComponent,
|
||||
FormFieldModule,
|
||||
@@ -35,6 +38,7 @@ import { OrgIconDirective } from "../../components/org-icon.directive";
|
||||
OrgIconDirective,
|
||||
FormFieldModule,
|
||||
ButtonLinkDirective,
|
||||
BadgeModule,
|
||||
],
|
||||
})
|
||||
export class ItemDetailsV2Component {
|
||||
@@ -85,7 +89,16 @@ export class ItemDetailsV2Component {
|
||||
}
|
||||
});
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
protected readonly showArchiveBadge = computed(() => {
|
||||
return (
|
||||
this.cipher().isArchived && this.platformUtilsService.getClientType() === ClientType.Desktop
|
||||
);
|
||||
});
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
|
||||
toggleShowMore() {
|
||||
this.showAllDetails.update((value) => !value);
|
||||
|
||||
Reference in New Issue
Block a user