1
0
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:
Jason Ng
2026-01-06 16:34:52 -05:00
committed by jaasen-livefront
parent 5acc0b4378
commit f7d358444f
19 changed files with 316 additions and 89 deletions

View File

@@ -585,6 +585,9 @@
"upgradeToUseArchive": {
"message": "A premium membership is required to use Archive."
},
"itemRestored": {
"message": "Item has been restored"
},
"edit": {
"message": "Edit"
},

View File

@@ -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, itll 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"
}
}
},

View File

@@ -7,9 +7,9 @@
[hidden]="action === 'view'"
bitButton
class="primary"
appA11yTitle="{{ 'save' | i18n }}"
appA11yTitle="{{ submitButtonText() }}"
>
{{ "save" | i18n }}
{{ submitButtonText() }}
</button>
<button
type="button"

View File

@@ -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;

View File

@@ -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"

View File

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

View File

@@ -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

View File

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

View File

@@ -3,7 +3,7 @@
{{ title }}
</span>
@if (cipherIsArchived) {
<span bitBadge bitDialogHeaderEnd> {{ "archiveNoun" | i18n }} </span>
<span bitBadge bitDialogHeaderEnd> {{ "archived" | i18n }} </span>
}
<div bitDialogContent #dialogContent>

View File

@@ -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"
},

View File

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

View File

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

View File

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

View File

@@ -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"

View File

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

View File

@@ -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,
);

View File

@@ -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">

View File

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

View File

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