mirror of
https://github.com/bitwarden/browser
synced 2025-12-19 17:53:39 +00:00
[PM-19032] Live Sync on Desktop (#13851)
* migrate the vault-items to an observables rather than async/promises - this helps keep data in sync with the service state and avoids race conditions * migrate the view component to an observables rather than async/promises - this helps keep data in sync with the service state and avoids race conditions
This commit is contained in:
@@ -28,9 +28,6 @@ export class VaultItemsComponent extends BaseVaultItemsComponent {
|
|||||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||||
searchBarService.searchText$.pipe(distinctUntilChanged()).subscribe((searchText) => {
|
searchBarService.searchText$.pipe(distinctUntilChanged()).subscribe((searchText) => {
|
||||||
this.searchText = searchText;
|
this.searchText = searchText;
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
this.search(200);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -497,7 +497,6 @@ export class VaultComponent implements OnInit, OnDestroy {
|
|||||||
this.action = "view";
|
this.action = "view";
|
||||||
await this.vaultItemsComponent.refresh();
|
await this.vaultItemsComponent.refresh();
|
||||||
await this.cipherService.clearCache(this.activeUserId);
|
await this.cipherService.clearCache(this.activeUserId);
|
||||||
await this.viewComponent.load();
|
|
||||||
this.go();
|
this.go();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,9 +120,7 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ngOnChanges() {
|
async ngOnChanges() {
|
||||||
await super.load();
|
if (this.cipher?.decryptionFailure) {
|
||||||
|
|
||||||
if (this.cipher.decryptionFailure) {
|
|
||||||
DecryptionFailureDialogComponent.open(this.dialogService, {
|
DecryptionFailureDialogComponent.open(this.dialogService, {
|
||||||
cipherIds: [this.cipherId as CipherId],
|
cipherIds: [this.cipherId as CipherId],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||||
import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
|
import { BehaviorSubject, Subject, combineLatest, filter, from, switchMap, takeUntil } from "rxjs";
|
||||||
|
|
||||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
|
||||||
@@ -21,16 +21,17 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
loaded = false;
|
loaded = false;
|
||||||
ciphers: CipherView[] = [];
|
ciphers: CipherView[] = [];
|
||||||
filter: (cipher: CipherView) => boolean = null;
|
|
||||||
deleted = false;
|
deleted = false;
|
||||||
organization: Organization;
|
organization: Organization;
|
||||||
|
|
||||||
protected searchPending = false;
|
protected searchPending = false;
|
||||||
|
|
||||||
|
/** Construct filters as an observable so it can be appended to the cipher stream. */
|
||||||
|
private _filter$ = new BehaviorSubject<(cipher: CipherView) => boolean | null>(null);
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
private searchTimeout: any = null;
|
|
||||||
private isSearchable: boolean = false;
|
private isSearchable: boolean = false;
|
||||||
private _searchText$ = new BehaviorSubject<string>("");
|
private _searchText$ = new BehaviorSubject<string>("");
|
||||||
|
|
||||||
get searchText() {
|
get searchText() {
|
||||||
return this._searchText$.value;
|
return this._searchText$.value;
|
||||||
}
|
}
|
||||||
@@ -38,11 +39,21 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
|||||||
this._searchText$.next(value);
|
this._searchText$.next(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get filter() {
|
||||||
|
return this._filter$.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set filter(value: (cipher: CipherView) => boolean | null) {
|
||||||
|
this._filter$.next(value);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected searchService: SearchService,
|
protected searchService: SearchService,
|
||||||
protected cipherService: CipherService,
|
protected cipherService: CipherService,
|
||||||
protected accountService: AccountService,
|
protected accountService: AccountService,
|
||||||
) {}
|
) {
|
||||||
|
this.subscribeToCiphers();
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this._searchText$
|
this._searchText$
|
||||||
@@ -77,23 +88,6 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
async applyFilter(filter: (cipher: CipherView) => boolean = null) {
|
async applyFilter(filter: (cipher: CipherView) => boolean = null) {
|
||||||
this.filter = filter;
|
this.filter = filter;
|
||||||
await this.search(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async search(timeout: number = null, indexedCiphers?: CipherView[]) {
|
|
||||||
this.searchPending = false;
|
|
||||||
if (this.searchTimeout != null) {
|
|
||||||
clearTimeout(this.searchTimeout);
|
|
||||||
}
|
|
||||||
if (timeout == null) {
|
|
||||||
await this.doSearch(indexedCiphers);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.searchPending = true;
|
|
||||||
this.searchTimeout = setTimeout(async () => {
|
|
||||||
await this.doSearch(indexedCiphers);
|
|
||||||
this.searchPending = false;
|
|
||||||
}, timeout);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
selectCipher(cipher: CipherView) {
|
selectCipher(cipher: CipherView) {
|
||||||
@@ -118,24 +112,42 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted;
|
protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted;
|
||||||
|
|
||||||
protected async doSearch(indexedCiphers?: CipherView[], userId?: UserId) {
|
/**
|
||||||
// Get userId from activeAccount if not provided from parent stream
|
* Creates stream of dependencies that results in the list of ciphers to display
|
||||||
if (!userId) {
|
* within the vault list.
|
||||||
userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
*
|
||||||
}
|
* Note: This previously used promises but race conditions with how the ciphers were
|
||||||
|
* stored in electron. Using observables is more reliable as fresh values will always
|
||||||
|
* cascade through the components.
|
||||||
|
*/
|
||||||
|
private subscribeToCiphers() {
|
||||||
|
getUserId(this.accountService.activeAccount$)
|
||||||
|
.pipe(
|
||||||
|
switchMap((userId) =>
|
||||||
|
combineLatest([
|
||||||
|
this.cipherService.cipherViews$(userId).pipe(filter((ciphers) => ciphers != null)),
|
||||||
|
this.cipherService.failedToDecryptCiphers$(userId),
|
||||||
|
this._searchText$,
|
||||||
|
this._filter$,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
switchMap(([indexedCiphers, failedCiphers, searchText, filter]) => {
|
||||||
|
let allCiphers = indexedCiphers ?? [];
|
||||||
|
const _failedCiphers = failedCiphers ?? [];
|
||||||
|
|
||||||
indexedCiphers =
|
allCiphers = [..._failedCiphers, ...allCiphers];
|
||||||
indexedCiphers ?? (await firstValueFrom(this.cipherService.cipherViews$(userId)));
|
|
||||||
|
|
||||||
const failedCiphers = await firstValueFrom(this.cipherService.failedToDecryptCiphers$(userId));
|
return this.searchService.searchCiphers(
|
||||||
if (failedCiphers != null && failedCiphers.length > 0) {
|
searchText,
|
||||||
indexedCiphers = [...failedCiphers, ...indexedCiphers];
|
[filter, this.deletedFilter],
|
||||||
}
|
allCiphers,
|
||||||
|
|
||||||
this.ciphers = await this.searchService.searchCiphers(
|
|
||||||
this.searchText,
|
|
||||||
[this.filter, this.deletedFilter],
|
|
||||||
indexedCiphers,
|
|
||||||
);
|
);
|
||||||
|
}),
|
||||||
|
takeUntilDestroyed(),
|
||||||
|
)
|
||||||
|
.subscribe((ciphers) => {
|
||||||
|
this.ciphers = ciphers;
|
||||||
|
this.loaded = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,17 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
Output,
|
Output,
|
||||||
} from "@angular/core";
|
} from "@angular/core";
|
||||||
import { filter, firstValueFrom, map, Observable } from "rxjs";
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
combineLatest,
|
||||||
|
filter,
|
||||||
|
firstValueFrom,
|
||||||
|
map,
|
||||||
|
Observable,
|
||||||
|
of,
|
||||||
|
switchMap,
|
||||||
|
tap,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||||
@@ -49,7 +59,18 @@ const BroadcasterSubscriptionId = "BaseViewComponent";
|
|||||||
|
|
||||||
@Directive()
|
@Directive()
|
||||||
export class ViewComponent implements OnDestroy, OnInit {
|
export class ViewComponent implements OnDestroy, OnInit {
|
||||||
@Input() cipherId: string;
|
/** Observable of cipherId$ that will update each time the `Input` updates */
|
||||||
|
private _cipherId$ = new BehaviorSubject<string>(null);
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
set cipherId(value: string) {
|
||||||
|
this._cipherId$.next(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
get cipherId(): string {
|
||||||
|
return this._cipherId$.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
@Input() collectionId: string;
|
@Input() collectionId: string;
|
||||||
@Output() onEditCipher = new EventEmitter<CipherView>();
|
@Output() onEditCipher = new EventEmitter<CipherView>();
|
||||||
@Output() onCloneCipher = new EventEmitter<CipherView>();
|
@Output() onCloneCipher = new EventEmitter<CipherView>();
|
||||||
@@ -125,13 +146,30 @@ export class ViewComponent implements OnDestroy, OnInit {
|
|||||||
switch (message.command) {
|
switch (message.command) {
|
||||||
case "syncCompleted":
|
case "syncCompleted":
|
||||||
if (message.successfully) {
|
if (message.successfully) {
|
||||||
await this.load();
|
|
||||||
this.changeDetectorRef.detectChanges();
|
this.changeDetectorRef.detectChanges();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set up the subscription to the activeAccount$ and cipherId$ observables
|
||||||
|
combineLatest([this.accountService.activeAccount$.pipe(getUserId), this._cipherId$])
|
||||||
|
.pipe(
|
||||||
|
tap(() => this.cleanUp()),
|
||||||
|
switchMap(([userId, cipherId]) => {
|
||||||
|
const cipher$ = this.cipherService.cipherViews$(userId).pipe(
|
||||||
|
map((ciphers) => ciphers?.find((c) => c.id === cipherId)),
|
||||||
|
filter((cipher) => !!cipher),
|
||||||
|
);
|
||||||
|
return combineLatest([of(userId), cipher$]);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe(([userId, cipher]) => {
|
||||||
|
this.cipher = cipher;
|
||||||
|
|
||||||
|
void this.constructCipherDetails(userId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
@@ -139,55 +177,6 @@ export class ViewComponent implements OnDestroy, OnInit {
|
|||||||
this.cleanUp();
|
this.cleanUp();
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
|
||||||
this.cleanUp();
|
|
||||||
|
|
||||||
// Grab individual cipher from `cipherViews$` for the most up-to-date information
|
|
||||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
|
||||||
this.cipher = await firstValueFrom(
|
|
||||||
this.cipherService.cipherViews$(activeUserId).pipe(
|
|
||||||
map((ciphers) => ciphers?.find((c) => c.id === this.cipherId)),
|
|
||||||
filter((cipher) => !!cipher),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.canAccessPremium = await firstValueFrom(
|
|
||||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(activeUserId),
|
|
||||||
);
|
|
||||||
this.showPremiumRequiredTotp =
|
|
||||||
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;
|
|
||||||
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
|
|
||||||
this.collectionId as CollectionId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (this.cipher.folderId) {
|
|
||||||
this.folder = await (
|
|
||||||
await firstValueFrom(this.folderService.folderViews$(activeUserId))
|
|
||||||
).find((f) => f.id == this.cipher.folderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.cipher.type === CipherType.Login &&
|
|
||||||
this.cipher.login.totp &&
|
|
||||||
(this.cipher.organizationUseTotp || this.canAccessPremium)
|
|
||||||
) {
|
|
||||||
await this.totpUpdateCode();
|
|
||||||
const interval = this.totpService.getTimeInterval(this.cipher.login.totp);
|
|
||||||
await this.totpTick(interval);
|
|
||||||
|
|
||||||
this.totpInterval = setInterval(async () => {
|
|
||||||
await this.totpTick(interval);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.previousCipherId !== this.cipherId) {
|
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
this.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId);
|
|
||||||
}
|
|
||||||
this.previousCipherId = this.cipherId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async edit() {
|
async edit() {
|
||||||
if (await this.promptPassword()) {
|
if (await this.promptPassword()) {
|
||||||
this.onEditCipher.emit(this.cipher);
|
this.onEditCipher.emit(this.cipher);
|
||||||
@@ -567,4 +556,46 @@ export class ViewComponent implements OnDestroy, OnInit {
|
|||||||
await this.totpUpdateCode();
|
await this.totpUpdateCode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a cipher is viewed, construct all details for the view that are not directly
|
||||||
|
* available from the cipher object itself.
|
||||||
|
*/
|
||||||
|
private async constructCipherDetails(userId: UserId) {
|
||||||
|
this.canAccessPremium = await firstValueFrom(
|
||||||
|
this.billingAccountProfileStateService.hasPremiumFromAnySource$(userId),
|
||||||
|
);
|
||||||
|
this.showPremiumRequiredTotp =
|
||||||
|
this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp;
|
||||||
|
this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [
|
||||||
|
this.collectionId as CollectionId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (this.cipher.folderId) {
|
||||||
|
this.folder = await (
|
||||||
|
await firstValueFrom(this.folderService.folderViews$(userId))
|
||||||
|
).find((f) => f.id == this.cipher.folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.cipher.type === CipherType.Login &&
|
||||||
|
this.cipher.login.totp &&
|
||||||
|
(this.cipher.organizationUseTotp || this.canAccessPremium)
|
||||||
|
) {
|
||||||
|
await this.totpUpdateCode();
|
||||||
|
const interval = this.totpService.getTimeInterval(this.cipher.login.totp);
|
||||||
|
await this.totpTick(interval);
|
||||||
|
|
||||||
|
this.totpInterval = setInterval(async () => {
|
||||||
|
await this.totpTick(interval);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.previousCipherId !== this.cipherId) {
|
||||||
|
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
this.eventCollectionService.collect(EventType.Cipher_ClientViewed, this.cipherId);
|
||||||
|
}
|
||||||
|
this.previousCipherId = this.cipherId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user