mirror of
https://github.com/bitwarden/browser
synced 2025-12-13 14:53:33 +00:00
[PM-18903] Desktop sync issues (#13681)
* [PM-18707] Use different BroadcasterSubscriptionId in base view component to avoid collision with desktop view component * [PM-18707] Use userId instead of payloadUserId for cipher notification syncs * [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 * decrypt saved cipher from server * bump timeout for upserting ciphers * mark `go` as async in desktop vault - previously it was a floating promise * Revert "mark `go` as async in desktop vault" This reverts commitfd28f40b18. * Revert "bump timeout for upserting ciphers" This reverts commite963acc377. * move vault utilities to `common` rather than `lib` to avoid circular dependencies * use `perUserCache$` for `cipherViews$` to avoid new subscriptions from being created * use userId from observable rather than locally set to be the most up to date * [PM-18707] Add clearBuffer$ input to perUserCache$ helper so that the internal share replay buffers can be cleared * [PM-18707] Rework forceCipherViews$ to clearBuffer$ refactor - Add dependency for cipherDecryptionKeys$ for the cipherViews so that decryption is never attempted without keys * [PM-18707] Add overload to perUserCache to satisfy type checker * [PM-18707] Fix overloads * [PM-18707] Add check for empty failed to decrypt ciphers * [PM-18707] Mark vault component for check after observable emits. The cipherViews$ observable now persists between subscriptions, meaning that updates via the sync push notifications can occur outside the AngularZone causing delays in updating the view. --------- Co-authored-by: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Co-authored-by: Nick Krantz <nick@livefront.com>
This commit is contained in:
@@ -11,7 +11,17 @@ import {
|
||||
OnInit,
|
||||
Output,
|
||||
} 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 { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
@@ -46,11 +56,22 @@ import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
const BroadcasterSubscriptionId = "ViewComponent";
|
||||
const BroadcasterSubscriptionId = "BaseViewComponent";
|
||||
|
||||
@Directive()
|
||||
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;
|
||||
@Output() onEditCipher = new EventEmitter<CipherView>();
|
||||
@Output() onCloneCipher = new EventEmitter<CipherView>();
|
||||
@@ -126,13 +147,30 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
switch (message.command) {
|
||||
case "syncCompleted":
|
||||
if (message.successfully) {
|
||||
await this.load();
|
||||
this.changeDetectorRef.detectChanges();
|
||||
}
|
||||
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() {
|
||||
@@ -140,70 +178,6 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
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,
|
||||
]);
|
||||
this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher);
|
||||
|
||||
if (this.cipher.folderId) {
|
||||
this.folder = await (
|
||||
await firstValueFrom(this.folderService.folderViews$(activeUserId))
|
||||
).find((f) => f.id == this.cipher.folderId);
|
||||
}
|
||||
|
||||
const canGenerateTotp =
|
||||
this.cipher.type === CipherType.Login &&
|
||||
this.cipher.login.totp &&
|
||||
(this.cipher.organizationUseTotp || this.canAccessPremium);
|
||||
|
||||
this.totpInfo$ = canGenerateTotp
|
||||
? this.totpService.getCode$(this.cipher.login.totp).pipe(
|
||||
map((response) => {
|
||||
const epoch = Math.round(new Date().getTime() / 1000.0);
|
||||
const mod = epoch % response.period;
|
||||
|
||||
// Format code
|
||||
const totpCodeFormatted =
|
||||
response.code.length > 4
|
||||
? `${response.code.slice(0, Math.floor(response.code.length / 2))} ${response.code.slice(Math.floor(response.code.length / 2))}`
|
||||
: response.code;
|
||||
|
||||
return {
|
||||
totpCode: response.code,
|
||||
totpCodeFormatted,
|
||||
totpDash: +(Math.round(((78.6 / response.period) * mod + "e+2") as any) + "e-2"),
|
||||
totpSec: response.period - mod,
|
||||
totpLow: response.period - mod <= 7,
|
||||
} as TotpInfo;
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
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() {
|
||||
this.onEditCipher.emit(this.cipher);
|
||||
}
|
||||
@@ -533,4 +507,61 @@ export class ViewComponent implements OnDestroy, OnInit {
|
||||
this.showCardCode = false;
|
||||
this.passwordReprompted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
]);
|
||||
this.canRestoreCipher$ = this.cipherAuthorizationService.canRestoreCipher$(this.cipher);
|
||||
|
||||
if (this.cipher.folderId) {
|
||||
this.folder = await (
|
||||
await firstValueFrom(this.folderService.folderViews$(userId))
|
||||
).find((f) => f.id == this.cipher.folderId);
|
||||
}
|
||||
|
||||
const canGenerateTotp =
|
||||
this.cipher.type === CipherType.Login &&
|
||||
this.cipher.login.totp &&
|
||||
(this.cipher.organizationUseTotp || this.canAccessPremium);
|
||||
|
||||
this.totpInfo$ = canGenerateTotp
|
||||
? this.totpService.getCode$(this.cipher.login.totp).pipe(
|
||||
map((response) => {
|
||||
const epoch = Math.round(new Date().getTime() / 1000.0);
|
||||
const mod = epoch % response.period;
|
||||
|
||||
// Format code
|
||||
const totpCodeFormatted =
|
||||
response.code.length > 4
|
||||
? `${response.code.slice(0, Math.floor(response.code.length / 2))} ${response.code.slice(Math.floor(response.code.length / 2))}`
|
||||
: response.code;
|
||||
|
||||
return {
|
||||
totpCode: response.code,
|
||||
totpCodeFormatted,
|
||||
totpDash: +(Math.round(((78.6 / response.period) * mod + "e+2") as any) + "e-2"),
|
||||
totpSec: response.period - mod,
|
||||
totpLow: response.period - mod <= 7,
|
||||
} as TotpInfo;
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
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