1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

[PM-19339] Archived Items view for Browser (#13893)

* [PM-19339] Add link to archive page

* [PM-19339] Add archivedCiphers$ to vault-popup-items service

* [PM-19339] Add archive ciphers page

* [PM-19339] Fix bug when clearing cipher cache in vault popup items service

* [PM-19339] Add missing archivedDate to a few models and clean up types

* [PM-19339] Hide browser archive changes behind feature flag

* [PM-19339] Prevent autofill of archived ciphers when feature flag is enabled
This commit is contained in:
Shane Melton
2025-03-20 12:38:50 -07:00
committed by GitHub
parent 99514fe576
commit 88852613c4
13 changed files with 356 additions and 25 deletions

View File

@@ -544,6 +544,21 @@
"searchVault": {
"message": "Search vault"
},
"archive": {
"message": "Archive"
},
"unarchive": {
"message": "Unarchive"
},
"itemsInArchive": {
"message": "Items in archive"
},
"noItemsInArchive": {
"message": "No items in archive"
},
"noItemsInArchiveDesc": {
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
},
"edit": {
"message": "Edit"
},

View File

@@ -21,11 +21,13 @@ import {
AnonLayoutWrapperComponent,
AnonLayoutWrapperData,
DevicesIcon,
DeviceVerificationIcon,
LockIcon,
LoginComponent,
LoginDecryptionOptionsComponent,
LoginSecondaryContentComponent,
LoginViaAuthRequestComponent,
NewDeviceVerificationComponent,
PasswordHintComponent,
RegistrationFinishComponent,
RegistrationLockAltIcon,
@@ -35,11 +37,9 @@ import {
RegistrationUserAddIcon,
SetPasswordJitComponent,
SsoComponent,
TwoFactorTimeoutIcon,
TwoFactorAuthComponent,
TwoFactorAuthGuard,
NewDeviceVerificationComponent,
DeviceVerificationIcon,
TwoFactorTimeoutIcon,
UserLockIcon,
VaultIcon,
} from "@bitwarden/auth/angular";
@@ -90,6 +90,7 @@ import { PasswordHistoryV2Component } from "../vault/popup/components/vault-v2/v
import { VaultV2Component } from "../vault/popup/components/vault-v2/vault-v2.component";
import { ViewV2Component } from "../vault/popup/components/vault-v2/view-v2/view-v2.component";
import { AppearanceV2Component } from "../vault/popup/settings/appearance-v2.component";
import { ArchiveComponent } from "../vault/popup/settings/archive.component";
import { FoldersV2Component } from "../vault/popup/settings/folders-v2.component";
import { TrashComponent } from "../vault/popup/settings/trash.component";
import { VaultSettingsV2Component } from "../vault/popup/settings/vault-settings-v2.component";
@@ -691,6 +692,12 @@ const routes: Routes = [
canActivate: [authGuard],
data: { elevation: 2 } satisfies RouteDataProperties,
},
{
path: "archive",
component: ArchiveComponent,
canActivate: [authGuard],
data: { elevation: 2 } satisfies RouteDataProperties,
},
];
@Injectable()

View File

@@ -1,7 +1,7 @@
import { WritableSignal, signal } from "@angular/core";
import { TestBed, discardPeriodicTasks, fakeAsync, tick } from "@angular/core/testing";
import { signal, WritableSignal } from "@angular/core";
import { discardPeriodicTasks, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, timeout } from "rxjs";
import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
@@ -9,9 +9,10 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ObservableTracker, mockAccountServiceWith } from "@bitwarden/common/spec";
import { mockAccountServiceWith, ObservableTracker } from "@bitwarden/common/spec";
import { CipherId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { VaultSettingsService } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
@@ -36,7 +37,7 @@ describe("VaultPopupItemsService", () => {
let mockOrg: Organization;
let mockCollections: CollectionView[];
let activeUserLastSync$: BehaviorSubject<Date>;
let activeUserLastSync$: BehaviorSubject<Date | null>;
let viewCacheService: {
signal: jest.Mock;
mockSignal: WritableSignal<string | null>;
@@ -57,6 +58,7 @@ describe("VaultPopupItemsService", () => {
const inlineMenuFieldQualificationServiceMock = mock<InlineMenuFieldQualificationService>();
const userId = Utils.newGuid() as UserId;
const accountServiceMock = mockAccountServiceWith(userId);
const configServiceMock = mock<ConfigService>();
beforeEach(() => {
allCiphers = cipherFactory(10);
@@ -87,7 +89,7 @@ describe("VaultPopupItemsService", () => {
failedToDecryptCiphersSubject.asObservable(),
);
searchService.searchCiphers.mockImplementation(async (userId, _, __, ciphers) => ciphers);
searchService.searchCiphers.mockImplementation(async (userId, _, __, ciphers) => ciphers!);
cipherServiceMock.filterCiphersForUrl.mockImplementation(async (ciphers) =>
ciphers.filter((c) => ["0", "1"].includes(c.id)),
);
@@ -128,8 +130,9 @@ describe("VaultPopupItemsService", () => {
organizationServiceMock.organizations$.mockReturnValue(new BehaviorSubject([mockOrg]));
collectionService.decryptedCollections$ = new BehaviorSubject(mockCollections);
activeUserLastSync$ = new BehaviorSubject(new Date());
activeUserLastSync$ = new BehaviorSubject<Date | null>(new Date());
syncServiceMock.activeUserLastSync$.mockReturnValue(activeUserLastSync$);
configServiceMock.getFeatureFlag$.mockReturnValue(of(true));
const testSearchSignal = createMockSignal<string | null>("");
viewCacheService = {
@@ -154,6 +157,7 @@ describe("VaultPopupItemsService", () => {
useValue: inlineMenuFieldQualificationServiceMock,
},
{ provide: PopupViewCacheService, useValue: viewCacheService },
{ provide: ConfigService, useValue: configServiceMock },
],
});
@@ -277,7 +281,7 @@ describe("VaultPopupItemsService", () => {
const searchText = "Login";
searchService.searchCiphers.mockImplementation(async (userId, q, _, ciphers) => {
return ciphers.filter((cipher) => {
return ciphers!.filter((cipher) => {
return cipher.name.includes(searchText);
});
});
@@ -366,11 +370,11 @@ describe("VaultPopupItemsService", () => {
});
});
it("should return true when all ciphers are deleted", (done) => {
it("should return true when all ciphers are deleted/archived", (done) => {
cipherServiceMock.getAllDecrypted.mockResolvedValue([
{ id: "1", type: CipherType.Login, name: "Login 1", isDeleted: true },
{ id: "2", type: CipherType.Login, name: "Login 2", isDeleted: true },
{ id: "3", type: CipherType.Login, name: "Login 3", isDeleted: true },
{ id: "3", type: CipherType.Login, name: "Login 3", isDeleted: false, isArchived: true },
] as CipherView[]);
service.emptyVault$.subscribe((empty) => {

View File

@@ -24,6 +24,8 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/platform/sync";
import { CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
@@ -108,20 +110,31 @@ export class VaultPopupItemsService {
this.cipherService.failedToDecryptCiphers$(userId),
]),
),
map(([ciphers, failedToDecryptCiphers]) => [...failedToDecryptCiphers, ...ciphers]),
map(([ciphers, failedToDecryptCiphers]) => [
...(failedToDecryptCiphers ?? []),
...(ciphers ?? []),
]),
),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
private archiveFeatureFlag$ = this.configService.getFeatureFlag$(
FeatureFlag.PM19148_InnovationArchive,
);
private _activeCipherList$: Observable<PopupCipherView[]> = this._allDecryptedCiphers$.pipe(
switchMap((ciphers) =>
combineLatest([this.organizations$, this.collectionService.decryptedCollections$]).pipe(
map(([organizations, collections]) => {
combineLatest([
this.organizations$,
this.collectionService.decryptedCollections$,
this.archiveFeatureFlag$,
]).pipe(
map(([organizations, collections, archiveFeatureEnabled]) => {
const orgMap = Object.fromEntries(organizations.map((org) => [org.id, org]));
const collectionMap = Object.fromEntries(collections.map((col) => [col.id, col]));
return ciphers
.filter((c) => !c.isDeleted)
.filter((c) => !c.isDeleted && (!archiveFeatureEnabled || !c.isArchived))
.map(
(cipher) =>
new PopupCipherView(
@@ -295,6 +308,21 @@ export class VaultPopupItemsService {
shareReplay({ refCount: false, bufferSize: 1 }),
);
/**
* Observable that contains the list of ciphers that have been archived.
*/
archivedCiphers$: Observable<PopupCipherView[]> = this._allDecryptedCiphers$.pipe(
map((ciphers) => {
return (
ciphers
.filter((cipher) => cipher.isArchived && !cipher.isDeleted)
// Archived ciphers are individual only and never belong to an organization/collection
.map((cipher) => new PopupCipherView(cipher))
);
}),
shareReplay({ refCount: false, bufferSize: 1 }),
);
constructor(
private cipherService: CipherService,
private vaultSettingsService: VaultSettingsService,
@@ -306,6 +334,7 @@ export class VaultPopupItemsService {
private syncService: SyncService,
private accountService: AccountService,
private ngZone: NgZone,
private configService: ConfigService,
) {}
applyFilter(newSearchText: string) {

View File

@@ -0,0 +1,82 @@
<popup-page>
<popup-header slot="header" [pageTitle]="'archive' | i18n" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
</popup-header>
@if (archivedCiphers$ | async; as archivedItems) {
@if (archivedItems.length) {
<bit-section>
<bit-section-header>
<h2 bitTypography="h6">
{{ "itemsInArchive" | i18n }}
</h2>
<span bitTypography="body1" slot="end">{{ archivedItems.length }}</span>
</bit-section-header>
</bit-section>
<bit-item-group>
@for (cipher of archivedItems; track cipher.id) {
<bit-item>
<button
bit-item-content
type="button"
[appA11yTitle]="'viewItemTitle' | i18n: cipher.name"
(click)="view(cipher)"
>
<div slot="start" class="tw-justify-start tw-w-7 tw-flex">
<app-vault-icon [cipher]="cipher"></app-vault-icon>
</div>
<span data-testid="item-name">{{ cipher.name }}</span>
@if (cipher.hasAttachments) {
<i class="bwi bwi-paperclip bwi-sm" [appA11yTitle]="'attachments' | i18n"></i>
}
<span slot="secondary">{{ cipher.subTitle }}</span>
</button>
<bit-item-action slot="end">
<button
type="button"
bitIconButton="bwi-ellipsis-v"
size="small"
[attr.aria-label]="'moreOptionsLabel' | i18n: cipher.name"
[title]="'moreOptionsTitle' | i18n: cipher.name"
[bitMenuTriggerFor]="moreOptions"
></button>
<bit-menu #moreOptions>
<button type="button" bitMenuItem (click)="edit(cipher)">
{{ "edit" | i18n }}
</button>
<button type="button" bitMenuItem (click)="clone(cipher)">
{{ "clone" | i18n }}
</button>
<button type="button" bitMenuItem (click)="unarchive(cipher)">
{{ "unarchive" | i18n }}
</button>
<button
type="button"
bitMenuItem
*appCanDeleteCipher="cipher"
(click)="delete(cipher)"
>
<span class="tw-text-danger">
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</bit-item-action>
</bit-item>
}
</bit-item-group>
} @else {
<bit-no-items class="tw-flex tw-h-full tw-items-center tw-justify-center">
<ng-container slot="title">
{{ "noItemsInArchive" | i18n }}
</ng-container>
<ng-container slot="description">
{{ "noItemsInArchiveDesc" | i18n }}
</ng-container>
</bit-no-items>
}
}
</popup-page>

View File

@@ -0,0 +1,167 @@
import { CommonModule } from "@angular/common";
import { Component, inject } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
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 { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import {
DialogService,
IconButtonModule,
ItemModule,
MenuModule,
NoItemsModule,
SectionComponent,
SectionHeaderComponent,
ToastService,
TypographyModule,
} from "@bitwarden/components";
import {
CanDeleteCipherDirective,
DecryptionFailureDialogComponent,
PasswordRepromptService,
} from "@bitwarden/vault";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { VaultPopupItemsService } from "../services/vault-popup-items.service";
@Component({
templateUrl: "archive.component.html",
standalone: true,
imports: [
CommonModule,
JslibModule,
PopupPageComponent,
PopupHeaderComponent,
PopOutComponent,
NoItemsModule,
ItemModule,
MenuModule,
IconButtonModule,
CanDeleteCipherDirective,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
],
})
export class ArchiveComponent {
private vaultPopupItemsService = inject(VaultPopupItemsService);
private dialogService = inject(DialogService);
private passwordRepromptService = inject(PasswordRepromptService);
private router = inject(Router);
private cipherService = inject(CipherService);
private accountService = inject(AccountService);
private logService = inject(LogService);
private toastService = inject(ToastService);
private i18nService = inject(I18nService);
protected archivedCiphers$ = this.vaultPopupItemsService.archivedCiphers$;
async view(cipher: CipherView) {
if (!(await this.canInteract(cipher))) {
return;
}
await this.router.navigate(["/view-cipher"], {
queryParams: { cipherId: cipher.id, type: cipher.type },
});
}
async edit(cipher: CipherView) {
if (!(await this.canInteract(cipher))) {
return;
}
await this.router.navigate(["/edit-cipher"], {
queryParams: { cipherId: cipher.id, type: cipher.type },
});
}
async delete(cipher: CipherView) {
if (!(await this.canInteract(cipher, true))) {
return;
}
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "deleteItem" },
content: { key: "deleteItemConfirmation" },
type: "warning",
});
if (!confirmed) {
return;
}
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
try {
await this.cipherService.softDeleteWithServer(cipher.id, activeUserId);
} catch (e) {
this.logService.error(e);
return;
}
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("deletedItem"),
});
}
async unarchive(cipher: CipherView) {
if (!(await this.canInteract(cipher))) {
return;
}
// TODO: Implement once endpoint is available
}
async clone(cipher: CipherView) {
if (!(await this.canInteract(cipher))) {
return;
}
if (cipher.login?.hasFido2Credentials) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "passkeyNotCopied" },
content: { key: "passkeyNotCopiedAlert" },
type: "info",
});
if (!confirmed) {
return;
}
}
await this.router.navigate(["/clone-cipher"], {
queryParams: {
clone: true.toString(),
cipherId: cipher.id,
type: cipher.type,
},
});
}
/**
* Check if the user is able to interact with the cipher
* (password re-prompt / decryption failure checks).
* @param cipher
* @param ignoreDecryptionFailure - If true, the decryption failure check will be ignored.
* @private
*/
private async canInteract(cipher: CipherView, ignoreDecryptionFailure = false) {
if (cipher.decryptionFailure) {
DecryptionFailureDialogComponent.open(this.dialogService, {
cipherIds: [cipher.id as CipherId],
});
return false;
}
return await this.passwordRepromptService.passwordRepromptCheck(cipher);
}
}

View File

@@ -24,6 +24,12 @@
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
<bit-item *appIfFeature="FeatureFlag.PM19148_InnovationArchive">
<a bit-item-content routerLink="/archive">
{{ "archive" | i18n }}
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-item>
<bit-item>
<a bit-item-content routerLink="/trash">
{{ "trash" | i18n }}

View File

@@ -3,6 +3,7 @@ import { Component, OnInit } from "@angular/core";
import { Router, RouterModule } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { ItemModule, ToastOptions, ToastService } from "@bitwarden/components";
@@ -10,7 +11,6 @@ import { ItemModule, ToastOptions, ToastService } from "@bitwarden/components";
import { BrowserApi } from "../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
@@ -22,7 +22,6 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
JslibModule,
RouterModule,
PopupPageComponent,
PopupFooterComponent,
PopupHeaderComponent,
PopOutComponent,
ItemModule,
@@ -73,4 +72,6 @@ export class VaultSettingsV2Component implements OnInit {
this.lastSync = this.i18nService.t("never");
}
}
protected readonly FeatureFlag = FeatureFlag;
}

View File

@@ -39,7 +39,7 @@ export class CipherData {
passwordHistory?: PasswordHistoryData[];
collectionIds?: string[];
creationDate: string;
deletedDate: string;
deletedDate: string | null;
archivedDate: string | null;
reprompt: CipherRepromptType;
key: string;

View File

@@ -53,8 +53,8 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
passwordHistory: Password[];
collectionIds: string[];
creationDate: Date;
deletedDate: Date;
archivedDate: Date;
deletedDate: Date | null;
archivedDate: Date | null;
reprompt: CipherRepromptType;
key: EncString;

View File

@@ -33,6 +33,7 @@ export class CipherRequest {
attachments: { [id: string]: string };
attachments2: { [id: string]: AttachmentRequest };
lastKnownRevisionDate: Date;
archivedDate: Date | null;
reprompt: CipherRepromptType;
key: string;
@@ -44,6 +45,7 @@ export class CipherRequest {
this.notes = cipher.notes ? cipher.notes.encryptedString : null;
this.favorite = cipher.favorite;
this.lastKnownRevisionDate = cipher.revisionDate;
this.archivedDate = cipher.archivedDate;
this.reprompt = cipher.reprompt;
this.key = cipher.key?.encryptedString;

View File

@@ -45,8 +45,8 @@ export class CipherView implements View, InitializerMetadata {
collectionIds: string[] = null;
revisionDate: Date = null;
creationDate: Date = null;
deletedDate: Date = null;
archivedDate: Date = null;
deletedDate: Date | null = null;
archivedDate: Date | null = null;
reprompt: CipherRepromptType = CipherRepromptType.None;
/**
@@ -194,6 +194,7 @@ export class CipherView implements View, InitializerMetadata {
const view = new CipherView();
const revisionDate = obj.revisionDate == null ? null : new Date(obj.revisionDate);
const deletedDate = obj.deletedDate == null ? null : new Date(obj.deletedDate);
const archivedDate = obj.archivedDate == null ? null : new Date(obj.archivedDate);
const attachments = obj.attachments?.map((a: any) => AttachmentView.fromJSON(a));
const fields = obj.fields?.map((f: any) => FieldView.fromJSON(f));
const passwordHistory = obj.passwordHistory?.map((ph: any) => PasswordHistoryView.fromJSON(ph));
@@ -201,6 +202,7 @@ export class CipherView implements View, InitializerMetadata {
Object.assign(view, obj, {
revisionDate: revisionDate,
deletedDate: deletedDate,
archivedDate: archivedDate,
attachments: attachments,
fields: fields,
passwordHistory: passwordHistory,

View File

@@ -536,6 +536,10 @@ export class CipherService implements CipherServiceAbstraction {
);
defaultMatch ??= await firstValueFrom(this.domainSettingsService.defaultUriMatchStrategy$);
const archiveFeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM19148_InnovationArchive,
);
return ciphers.filter((cipher) => {
const cipherIsLogin = cipher.type === CipherType.Login && cipher.login !== null;
@@ -543,6 +547,10 @@ export class CipherService implements CipherServiceAbstraction {
return false;
}
if (archiveFeatureEnabled && cipher.isArchived) {
return false;
}
if (
Array.isArray(includeOtherTypes) &&
includeOtherTypes.includes(cipher.type) &&
@@ -564,8 +572,16 @@ export class CipherService implements CipherServiceAbstraction {
userId: UserId,
): Promise<CipherView[]> {
const ciphers = await this.getAllDecrypted(userId);
const archiveFeatureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM19148_InnovationArchive,
);
return ciphers
.filter((cipher) => cipher.deletedDate == null && type.includes(cipher.type))
.filter(
(cipher) =>
cipher.deletedDate == null &&
(!archiveFeatureEnabled || !cipher.isArchived) &&
type.includes(cipher.type),
)
.sort((a, b) => this.sortCiphersByLastUsedThenName(a, b));
}