1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 17:53:39 +00:00

Merge branch 'main' of github.com:bitwarden/clients

This commit is contained in:
gbubemismith
2024-04-30 10:11:34 -04:00
72 changed files with 1423 additions and 645 deletions

View File

@@ -49,7 +49,7 @@
<button <button
type="button" type="button"
class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3 disabled:tw-cursor-not-allowed disabled:tw-border-text-muted/60 disabled:!tw-text-muted/60" class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3 disabled:tw-cursor-not-allowed disabled:tw-border-text-muted/60 disabled:!tw-text-muted/60"
(click)="lock()" (click)="lock(currentAccount.id)"
[disabled]="currentAccount.status === lockedStatus || !activeUserCanLock" [disabled]="currentAccount.status === lockedStatus || !activeUserCanLock"
[title]="!activeUserCanLock ? ('unlockMethodNeeded' | i18n) : ''" [title]="!activeUserCanLock ? ('unlockMethodNeeded' | i18n) : ''"
> >
@@ -59,7 +59,7 @@
<button <button
type="button" type="button"
class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3" class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3"
(click)="logOut()" (click)="logOut(currentAccount.id)"
> >
<i class="bwi bwi-sign-out tw-text-2xl" aria-hidden="true"></i> <i class="bwi bwi-sign-out tw-text-2xl" aria-hidden="true"></i>
{{ "logOut" | i18n }} {{ "logOut" | i18n }}

View File

@@ -10,6 +10,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { AccountSwitcherService } from "./services/account-switcher.service"; import { AccountSwitcherService } from "./services/account-switcher.service";
@@ -64,9 +65,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
this.location.back(); this.location.back();
} }
async lock(userId?: string) { async lock(userId: string) {
this.loading = true; this.loading = true;
await this.vaultTimeoutService.lock(userId ? userId : null); await this.vaultTimeoutService.lock(userId);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["lock"]); this.router.navigate(["lock"]);
@@ -96,7 +97,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
.subscribe(() => this.router.navigate(["lock"])); .subscribe(() => this.router.navigate(["lock"]));
} }
async logOut() { async logOut(userId: UserId) {
this.loading = true; this.loading = true;
const confirmed = await this.dialogService.openSimpleDialog({ const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "logOut" }, title: { key: "logOut" },
@@ -105,7 +106,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
}); });
if (confirmed) { if (confirmed) {
this.messagingService.send("logout"); this.messagingService.send("logout", { userId });
} }
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.

View File

@@ -58,6 +58,7 @@ describe("AccountSwitcherService", () => {
const accountInfo: AccountInfo = { const accountInfo: AccountInfo = {
name: "Test User 1", name: "Test User 1",
email: "test1@email.com", email: "test1@email.com",
emailVerified: true,
}; };
avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc")); avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc"));
@@ -89,6 +90,7 @@ describe("AccountSwitcherService", () => {
for (let i = 0; i < numberOfAccounts; i++) { for (let i = 0; i < numberOfAccounts; i++) {
seedAccounts[`${i}` as UserId] = { seedAccounts[`${i}` as UserId] = {
email: `test${i}@email.com`, email: `test${i}@email.com`,
emailVerified: true,
name: "Test User ${i}", name: "Test User ${i}",
}; };
seedStatuses[`${i}` as UserId] = AuthenticationStatus.Unlocked; seedStatuses[`${i}` as UserId] = AuthenticationStatus.Unlocked;
@@ -113,6 +115,7 @@ describe("AccountSwitcherService", () => {
const user1AccountInfo: AccountInfo = { const user1AccountInfo: AccountInfo = {
name: "Test User 1", name: "Test User 1",
email: "", email: "",
emailVerified: true,
}; };
accountsSubject.next({ ["1" as UserId]: user1AccountInfo }); accountsSubject.next({ ["1" as UserId]: user1AccountInfo });
authStatusSubject.next({ ["1" as UserId]: AuthenticationStatus.LoggedOut }); authStatusSubject.next({ ["1" as UserId]: AuthenticationStatus.LoggedOut });

View File

@@ -59,7 +59,7 @@ export class LockComponent extends BaseLockComponent {
policyApiService: PolicyApiServiceAbstraction, policyApiService: PolicyApiServiceAbstraction,
policyService: InternalPolicyService, policyService: InternalPolicyService,
passwordStrengthService: PasswordStrengthServiceAbstraction, passwordStrengthService: PasswordStrengthServiceAbstraction,
private authService: AuthService, authService: AuthService,
dialogService: DialogService, dialogService: DialogService,
deviceTrustService: DeviceTrustServiceAbstraction, deviceTrustService: DeviceTrustServiceAbstraction,
userVerificationService: UserVerificationService, userVerificationService: UserVerificationService,
@@ -92,6 +92,7 @@ export class LockComponent extends BaseLockComponent {
pinCryptoService, pinCryptoService,
biometricStateService, biometricStateService,
accountService, accountService,
authService,
kdfConfigService, kdfConfigService,
); );
this.successRoute = "/tabs/current"; this.successRoute = "/tabs/current";

View File

@@ -11,7 +11,8 @@ import {
GENERATE_PASSWORD_ID, GENERATE_PASSWORD_ID,
NOOP_COMMAND_SUFFIX, NOOP_COMMAND_SUFFIX,
} from "@bitwarden/common/autofill/constants"; } from "@bitwarden/common/autofill/constants";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
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 { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
@@ -65,7 +66,7 @@ describe("ContextMenuClickedHandler", () => {
let autofill: AutofillAction; let autofill: AutofillAction;
let authService: MockProxy<AuthService>; let authService: MockProxy<AuthService>;
let cipherService: MockProxy<CipherService>; let cipherService: MockProxy<CipherService>;
let stateService: MockProxy<StateService>; let accountService: FakeAccountService;
let totpService: MockProxy<TotpService>; let totpService: MockProxy<TotpService>;
let eventCollectionService: MockProxy<EventCollectionService>; let eventCollectionService: MockProxy<EventCollectionService>;
let userVerificationService: MockProxy<UserVerificationService>; let userVerificationService: MockProxy<UserVerificationService>;
@@ -78,7 +79,7 @@ describe("ContextMenuClickedHandler", () => {
autofill = jest.fn<Promise<void>, [tab: chrome.tabs.Tab, cipher: CipherView]>(); autofill = jest.fn<Promise<void>, [tab: chrome.tabs.Tab, cipher: CipherView]>();
authService = mock(); authService = mock();
cipherService = mock(); cipherService = mock();
stateService = mock(); accountService = mockAccountServiceWith("userId" as UserId);
totpService = mock(); totpService = mock();
eventCollectionService = mock(); eventCollectionService = mock();
@@ -88,10 +89,10 @@ describe("ContextMenuClickedHandler", () => {
autofill, autofill,
authService, authService,
cipherService, cipherService,
stateService,
totpService, totpService,
eventCollectionService, eventCollectionService,
userVerificationService, userVerificationService,
accountService,
); );
}); });

View File

@@ -1,4 +1,7 @@
import { firstValueFrom, map } from "rxjs";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -17,7 +20,6 @@ import {
NOOP_COMMAND_SUFFIX, NOOP_COMMAND_SUFFIX,
} from "@bitwarden/common/autofill/constants"; } from "@bitwarden/common/autofill/constants";
import { EventType } from "@bitwarden/common/enums"; import { EventType } from "@bitwarden/common/enums";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -26,6 +28,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { accountServiceFactory } from "../../auth/background/service-factories/account-service.factory";
import { import {
authServiceFactory, authServiceFactory,
AuthServiceInitOptions, AuthServiceInitOptions,
@@ -37,7 +40,6 @@ import { autofillSettingsServiceFactory } from "../../autofill/background/servic
import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory"; import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory";
import { Account } from "../../models/account"; import { Account } from "../../models/account";
import { CachedServices } from "../../platform/background/service-factories/factory-options"; import { CachedServices } from "../../platform/background/service-factories/factory-options";
import { stateServiceFactory } from "../../platform/background/service-factories/state-service.factory";
import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserApi } from "../../platform/browser/browser-api";
import { passwordGenerationServiceFactory } from "../../tools/background/service_factories/password-generation-service.factory"; import { passwordGenerationServiceFactory } from "../../tools/background/service_factories/password-generation-service.factory";
import { import {
@@ -71,10 +73,10 @@ export class ContextMenuClickedHandler {
private autofillAction: AutofillAction, private autofillAction: AutofillAction,
private authService: AuthService, private authService: AuthService,
private cipherService: CipherService, private cipherService: CipherService,
private stateService: StateService,
private totpService: TotpService, private totpService: TotpService,
private eventCollectionService: EventCollectionService, private eventCollectionService: EventCollectionService,
private userVerificationService: UserVerificationService, private userVerificationService: UserVerificationService,
private accountService: AccountService,
) {} ) {}
static async mv3Create(cachedServices: CachedServices) { static async mv3Create(cachedServices: CachedServices) {
@@ -128,10 +130,10 @@ export class ContextMenuClickedHandler {
(tab, cipher) => autofillCommand.doAutofillTabWithCipherCommand(tab, cipher), (tab, cipher) => autofillCommand.doAutofillTabWithCipherCommand(tab, cipher),
await authServiceFactory(cachedServices, serviceOptions), await authServiceFactory(cachedServices, serviceOptions),
await cipherServiceFactory(cachedServices, serviceOptions), await cipherServiceFactory(cachedServices, serviceOptions),
await stateServiceFactory(cachedServices, serviceOptions),
await totpServiceFactory(cachedServices, serviceOptions), await totpServiceFactory(cachedServices, serviceOptions),
await eventCollectionServiceFactory(cachedServices, serviceOptions), await eventCollectionServiceFactory(cachedServices, serviceOptions),
await userVerificationServiceFactory(cachedServices, serviceOptions), await userVerificationServiceFactory(cachedServices, serviceOptions),
await accountServiceFactory(cachedServices, serviceOptions),
); );
} }
@@ -239,9 +241,10 @@ export class ContextMenuClickedHandler {
return; return;
} }
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. const activeUserId = await firstValueFrom(
// eslint-disable-next-line @typescript-eslint/no-floating-promises this.accountService.activeAccount$.pipe(map((a) => a?.id)),
this.stateService.setLastActive(new Date().getTime()); );
await this.accountService.setAccountActivity(activeUserId, new Date());
switch (info.parentMenuItemId) { switch (info.parentMenuItemId) {
case AUTOFILL_ID: case AUTOFILL_ID:
case AUTOFILL_IDENTITY_ID: case AUTOFILL_IDENTITY_ID:

View File

@@ -1,4 +1,4 @@
import { Subject, firstValueFrom, merge, timeout } from "rxjs"; import { Subject, firstValueFrom, map, merge, timeout } from "rxjs";
import { import {
PinCryptoServiceAbstraction, PinCryptoServiceAbstraction,
@@ -902,6 +902,7 @@ export default class MainBackground {
this.autofillSettingsService, this.autofillSettingsService,
this.vaultTimeoutSettingsService, this.vaultTimeoutSettingsService,
this.biometricStateService, this.biometricStateService,
this.accountService,
); );
// Other fields // Other fields
@@ -920,7 +921,6 @@ export default class MainBackground {
this.autofillService, this.autofillService,
this.platformUtilsService as BrowserPlatformUtilsService, this.platformUtilsService as BrowserPlatformUtilsService,
this.notificationsService, this.notificationsService,
this.stateService,
this.autofillSettingsService, this.autofillSettingsService,
this.systemService, this.systemService,
this.environmentService, this.environmentService,
@@ -929,6 +929,7 @@ export default class MainBackground {
this.configService, this.configService,
this.fido2Background, this.fido2Background,
messageListener, messageListener,
this.accountService,
); );
this.nativeMessagingBackground = new NativeMessagingBackground( this.nativeMessagingBackground = new NativeMessagingBackground(
this.accountService, this.accountService,
@@ -1018,10 +1019,10 @@ export default class MainBackground {
}, },
this.authService, this.authService,
this.cipherService, this.cipherService,
this.stateService,
this.totpService, this.totpService,
this.eventCollectionService, this.eventCollectionService,
this.userVerificationService, this.userVerificationService,
this.accountService,
); );
this.contextMenusBackground = new ContextMenusBackground(contextMenuClickedHandler); this.contextMenusBackground = new ContextMenusBackground(contextMenuClickedHandler);
@@ -1168,7 +1169,12 @@ export default class MainBackground {
*/ */
async switchAccount(userId: UserId) { async switchAccount(userId: UserId) {
try { try {
await this.stateService.setActiveUser(userId); const currentlyActiveAccount = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
// can be removed once password generation history is migrated to state providers
await this.stateService.clearDecryptedData(currentlyActiveAccount);
await this.accountService.switchAccount(userId);
if (userId == null) { if (userId == null) {
this.loginEmailService.setRememberEmail(false); this.loginEmailService.setRememberEmail(false);
@@ -1240,7 +1246,11 @@ export default class MainBackground {
//Needs to be checked before state is cleaned //Needs to be checked before state is cleaned
const needStorageReseed = await this.needsStorageReseed(); const needStorageReseed = await this.needsStorageReseed();
const newActiveUser = await this.stateService.clean({ userId: userId }); const newActiveUser = await firstValueFrom(
this.accountService.nextUpAccount$.pipe(map((a) => a?.id)),
);
await this.stateService.clean({ userId: userId });
await this.accountService.clean(userId);
await this.stateEventRunnerService.handleEvent("logout", userId); await this.stateEventRunnerService.handleEvent("logout", userId);

View File

@@ -1,6 +1,7 @@
import { firstValueFrom, mergeMap } from "rxjs"; import { firstValueFrom, map, mergeMap } from "rxjs";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -19,7 +20,6 @@ import {
import { LockedVaultPendingNotificationsData } from "../autofill/background/abstractions/notification.background"; import { LockedVaultPendingNotificationsData } from "../autofill/background/abstractions/notification.background";
import { AutofillService } from "../autofill/services/abstractions/autofill.service"; import { AutofillService } from "../autofill/services/abstractions/autofill.service";
import { BrowserApi } from "../platform/browser/browser-api"; import { BrowserApi } from "../platform/browser/browser-api";
import { BrowserStateService } from "../platform/services/abstractions/browser-state.service";
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service";
import { Fido2Background } from "../vault/fido2/background/abstractions/fido2.background"; import { Fido2Background } from "../vault/fido2/background/abstractions/fido2.background";
@@ -37,7 +37,6 @@ export default class RuntimeBackground {
private autofillService: AutofillService, private autofillService: AutofillService,
private platformUtilsService: BrowserPlatformUtilsService, private platformUtilsService: BrowserPlatformUtilsService,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private stateService: BrowserStateService,
private autofillSettingsService: AutofillSettingsServiceAbstraction, private autofillSettingsService: AutofillSettingsServiceAbstraction,
private systemService: SystemService, private systemService: SystemService,
private environmentService: BrowserEnvironmentService, private environmentService: BrowserEnvironmentService,
@@ -46,6 +45,7 @@ export default class RuntimeBackground {
private configService: ConfigService, private configService: ConfigService,
private fido2Background: Fido2Background, private fido2Background: Fido2Background,
private messageListener: MessageListener, private messageListener: MessageListener,
private accountService: AccountService,
) { ) {
// onInstalled listener must be wired up before anything else, so we do it in the ctor // onInstalled listener must be wired up before anything else, so we do it in the ctor
chrome.runtime.onInstalled.addListener((details: any) => { chrome.runtime.onInstalled.addListener((details: any) => {
@@ -111,9 +111,10 @@ export default class RuntimeBackground {
switch (msg.sender) { switch (msg.sender) {
case "autofiller": case "autofiller":
case "autofill_cmd": { case "autofill_cmd": {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. const activeUserId = await firstValueFrom(
// eslint-disable-next-line @typescript-eslint/no-floating-promises this.accountService.activeAccount$.pipe(map((a) => a?.id)),
this.stateService.setLastActive(new Date().getTime()); );
await this.accountService.setAccountActivity(activeUserId, new Date());
const totpCode = await this.autofillService.doAutoFillActiveTab( const totpCode = await this.autofillService.doAutoFillActiveTab(
[ [
{ {

View File

@@ -1,10 +1,8 @@
import { Component, Input } from "@angular/core"; import { Component, Input } from "@angular/core";
import { Observable, combineLatest, map, of, switchMap } from "rxjs"; import { Observable, map, of, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { UserId } from "@bitwarden/common/types/guid";
import { enableAccountSwitching } from "../flags"; import { enableAccountSwitching } from "../flags";
@@ -16,18 +14,15 @@ export class HeaderComponent {
@Input() noTheme = false; @Input() noTheme = false;
@Input() hideAccountSwitcher = false; @Input() hideAccountSwitcher = false;
authedAccounts$: Observable<boolean>; authedAccounts$: Observable<boolean>;
constructor(accountService: AccountService, authService: AuthService) { constructor(authService: AuthService) {
this.authedAccounts$ = accountService.accounts$.pipe( this.authedAccounts$ = authService.authStatuses$.pipe(
switchMap((accounts) => { map((record) => Object.values(record)),
switchMap((statuses) => {
if (!enableAccountSwitching()) { if (!enableAccountSwitching()) {
return of(false); return of(false);
} }
return combineLatest( return of(statuses.some((status) => status !== AuthenticationStatus.LoggedOut));
Object.keys(accounts).map((id) => authService.authStatusFor$(id as UserId)),
).pipe(
map((statuses) => statuses.some((status) => status !== AuthenticationStatus.LoggedOut)),
);
}), }),
); );
} }

View File

@@ -1,5 +1,4 @@
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -50,7 +49,6 @@ describe("Browser State Service", () => {
state.accounts[userId] = new Account({ state.accounts[userId] = new Account({
profile: { userId: userId }, profile: { userId: userId },
}); });
state.activeUserId = userId;
}); });
afterEach(() => { afterEach(() => {
@@ -78,18 +76,8 @@ describe("Browser State Service", () => {
); );
}); });
describe("add Account", () => { it("exists", () => {
it("should add account", async () => { expect(sut).toBeDefined();
const newUserId = "newUserId" as UserId;
const newAcct = new Account({
profile: { userId: newUserId },
});
await sut.addAccount(newAcct);
const accts = await firstValueFrom(sut.accounts$);
expect(accts[newUserId]).toBeDefined();
});
}); });
}); });
}); });

View File

@@ -29,8 +29,6 @@ export class DefaultBrowserStateService
initializeAs: "record", initializeAs: "record",
}) })
protected accountsSubject: BehaviorSubject<{ [userId: string]: Account }>; protected accountsSubject: BehaviorSubject<{ [userId: string]: Account }>;
@sessionSync({ initializer: (s: string) => s })
protected activeAccountSubject: BehaviorSubject<string>;
protected accountDeserializer = Account.fromJSON; protected accountDeserializer = Account.fromJSON;

View File

@@ -200,6 +200,7 @@ export class LocalBackedSessionStorageService
} }
private compareValues<T>(value1: T, value2: T): boolean { private compareValues<T>(value1: T, value2: T): boolean {
try {
if (value1 == null && value2 == null) { if (value1 == null && value2 == null) {
return true; return true;
} }
@@ -216,10 +217,12 @@ export class LocalBackedSessionStorageService
return value1 === value2; return value1 === value2;
} }
if (JSON.stringify(value1) === JSON.stringify(value2)) { return JSON.stringify(value1) === JSON.stringify(value2);
} catch (e) {
this.logService.error(
`error comparing values\n${JSON.stringify(value1)}\n${JSON.stringify(value2)}`,
);
return true; return true;
} }
return Object.entries(value1).sort().toString() === Object.entries(value2).sort().toString();
} }
} }

View File

@@ -1,12 +1,14 @@
import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core";
import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import { filter, concatMap, Subject, takeUntil, firstValueFrom, tap, map } from "rxjs"; import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { MessageListener } from "@bitwarden/common/platform/messaging"; import { MessageListener } from "@bitwarden/common/platform/messaging";
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 { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components"; import { DialogService, SimpleDialogOptions, ToastService } from "@bitwarden/components";
@@ -27,8 +29,9 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn
</div>`, </div>`,
}) })
export class AppComponent implements OnInit, OnDestroy { export class AppComponent implements OnInit, OnDestroy {
private lastActivity: number = null; private lastActivity: Date;
private activeUserId: string; private activeUserId: UserId;
private recordActivitySubject = new Subject<void>();
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -46,6 +49,7 @@ export class AppComponent implements OnInit, OnDestroy {
private dialogService: DialogService, private dialogService: DialogService,
private messageListener: MessageListener, private messageListener: MessageListener,
private toastService: ToastService, private toastService: ToastService,
private accountService: AccountService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -53,14 +57,13 @@ export class AppComponent implements OnInit, OnDestroy {
// Clear them aggressively to make sure this doesn't occur // Clear them aggressively to make sure this doesn't occur
await this.clearComponentStates(); await this.clearComponentStates();
this.stateService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((userId) => { this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => {
this.activeUserId = userId; this.activeUserId = account?.id;
}); });
this.authService.activeAccountStatus$ this.authService.activeAccountStatus$
.pipe( .pipe(
map((status) => status === AuthenticationStatus.Unlocked), filter((status) => status === AuthenticationStatus.Unlocked),
filter((unlocked) => unlocked),
concatMap(async () => { concatMap(async () => {
await this.recordActivity(); await this.recordActivity();
}), }),
@@ -200,13 +203,13 @@ export class AppComponent implements OnInit, OnDestroy {
return; return;
} }
const now = new Date().getTime(); const now = new Date();
if (this.lastActivity != null && now - this.lastActivity < 250) { if (this.lastActivity != null && now.getTime() - this.lastActivity.getTime() < 250) {
return; return;
} }
this.lastActivity = now; this.lastActivity = now;
await this.stateService.setLastActive(now, { userId: this.activeUserId }); await this.accountService.setAccountActivity(this.activeUserId, now);
} }
private showToast(msg: any) { private showToast(msg: any) {

View File

@@ -6,6 +6,7 @@ import { first } from "rxjs/operators";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -51,6 +52,7 @@ export class SendAddEditComponent extends BaseAddEditComponent {
formBuilder: FormBuilder, formBuilder: FormBuilder,
private filePopoutUtilsService: FilePopoutUtilsService, private filePopoutUtilsService: FilePopoutUtilsService,
billingAccountProfileStateService: BillingAccountProfileStateService, billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService,
) { ) {
super( super(
i18nService, i18nService,
@@ -66,6 +68,7 @@ export class SendAddEditComponent extends BaseAddEditComponent {
dialogService, dialogService,
formBuilder, formBuilder,
billingAccountProfileStateService, billingAccountProfileStateService,
accountService,
); );
} }

View File

@@ -731,7 +731,7 @@ export class Main {
this.authService.logOut(() => { this.authService.logOut(() => {
/* Do nothing */ /* Do nothing */
}); });
const userId = await this.stateService.getUserId(); const userId = (await this.stateService.getUserId()) as UserId;
await Promise.all([ await Promise.all([
this.eventUploadService.uploadEvents(userId as UserId), this.eventUploadService.uploadEvents(userId as UserId),
this.syncService.setLastSync(new Date(0)), this.syncService.setLastSync(new Date(0)),
@@ -742,9 +742,10 @@ export class Main {
this.passwordGenerationService.clear(), this.passwordGenerationService.clear(),
]); ]);
await this.stateEventRunnerService.handleEvent("logout", userId as UserId); await this.stateEventRunnerService.handleEvent("logout", userId);
await this.stateService.clean(); await this.stateService.clean();
await this.accountService.clean(userId);
process.env.BW_SESSION = null; process.env.BW_SESSION = null;
} }

View File

@@ -9,7 +9,7 @@ import {
} from "@bitwarden/angular/auth/guards"; } from "@bitwarden/angular/auth/guards";
import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component"; import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component";
import { LoginGuard } from "../auth/guards/login.guard"; import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
import { HintComponent } from "../auth/hint.component"; import { HintComponent } from "../auth/hint.component";
import { LockComponent } from "../auth/lock.component"; import { LockComponent } from "../auth/lock.component";
import { LoginDecryptionOptionsComponent } from "../auth/login/login-decryption-options/login-decryption-options.component"; import { LoginDecryptionOptionsComponent } from "../auth/login/login-decryption-options/login-decryption-options.component";
@@ -40,7 +40,7 @@ const routes: Routes = [
{ {
path: "login", path: "login",
component: LoginComponent, component: LoginComponent,
canActivate: [LoginGuard], canActivate: [maxAccountsGuardFn()],
}, },
{ {
path: "login-with-device", path: "login-with-device",

View File

@@ -8,7 +8,7 @@ import {
ViewContainerRef, ViewContainerRef,
} from "@angular/core"; } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { firstValueFrom, Subject, takeUntil } from "rxjs"; import { firstValueFrom, map, Subject, takeUntil } from "rxjs";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.service"; import { ModalService } from "@bitwarden/angular/services/modal.service";
@@ -18,9 +18,9 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notificatio
import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
@@ -107,11 +107,11 @@ export class AppComponent implements OnInit, OnDestroy {
loading = false; loading = false;
private lastActivity: number = null; private lastActivity: Date = null;
private modal: ModalRef = null; private modal: ModalRef = null;
private idleTimer: number = null; private idleTimer: number = null;
private isIdle = false; private isIdle = false;
private activeUserId: string = null; private activeUserId: UserId = null;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -150,12 +150,12 @@ export class AppComponent implements OnInit, OnDestroy {
private biometricStateService: BiometricStateService, private biometricStateService: BiometricStateService,
private stateEventRunnerService: StateEventRunnerService, private stateEventRunnerService: StateEventRunnerService,
private providerService: ProviderService, private providerService: ProviderService,
private organizationService: InternalOrganizationServiceAbstraction, private accountService: AccountService,
) {} ) {}
ngOnInit() { ngOnInit() {
this.stateService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((userId) => { this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => {
this.activeUserId = userId; this.activeUserId = account?.id;
}); });
this.ngZone.runOutsideAngular(() => { this.ngZone.runOutsideAngular(() => {
@@ -400,7 +400,8 @@ export class AppComponent implements OnInit, OnDestroy {
break; break;
case "switchAccount": { case "switchAccount": {
if (message.userId != null) { if (message.userId != null) {
await this.stateService.setActiveUser(message.userId); await this.stateService.clearDecryptedData(message.userId);
await this.accountService.switchAccount(message.userId);
} }
const locked = const locked =
(await this.authService.getAuthStatus(message.userId)) === (await this.authService.getAuthStatus(message.userId)) ===
@@ -522,7 +523,7 @@ export class AppComponent implements OnInit, OnDestroy {
private async updateAppMenu() { private async updateAppMenu() {
let updateRequest: MenuUpdateRequest; let updateRequest: MenuUpdateRequest;
const stateAccounts = await firstValueFrom(this.stateService.accounts$); const stateAccounts = await firstValueFrom(this.accountService.accounts$);
if (stateAccounts == null || Object.keys(stateAccounts).length < 1) { if (stateAccounts == null || Object.keys(stateAccounts).length < 1) {
updateRequest = { updateRequest = {
accounts: null, accounts: null,
@@ -531,32 +532,32 @@ export class AppComponent implements OnInit, OnDestroy {
} else { } else {
const accounts: { [userId: string]: MenuAccount } = {}; const accounts: { [userId: string]: MenuAccount } = {};
for (const i in stateAccounts) { for (const i in stateAccounts) {
const userId = i as UserId;
if ( if (
i != null && i != null &&
stateAccounts[i]?.profile?.userId != null && userId != null &&
!this.isAccountCleanUpInProgress(stateAccounts[i].profile.userId) // skip accounts that are being cleaned up !this.isAccountCleanUpInProgress(userId) // skip accounts that are being cleaned up
) { ) {
const userId = stateAccounts[i].profile.userId;
const availableTimeoutActions = await firstValueFrom( const availableTimeoutActions = await firstValueFrom(
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId), this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
); );
const authStatus = await firstValueFrom(this.authService.authStatusFor$(userId));
accounts[userId] = { accounts[userId] = {
isAuthenticated: await this.stateService.getIsAuthenticated({ isAuthenticated: authStatus >= AuthenticationStatus.Locked,
userId: userId, isLocked: authStatus === AuthenticationStatus.Locked,
}),
isLocked:
(await this.authService.getAuthStatus(userId)) === AuthenticationStatus.Locked,
isLockable: availableTimeoutActions.includes(VaultTimeoutAction.Lock), isLockable: availableTimeoutActions.includes(VaultTimeoutAction.Lock),
email: stateAccounts[i].profile.email, email: stateAccounts[userId].email,
userId: stateAccounts[i].profile.userId, userId: userId,
hasMasterPassword: await this.userVerificationService.hasMasterPassword(userId), hasMasterPassword: await this.userVerificationService.hasMasterPassword(userId),
}; };
} }
} }
updateRequest = { updateRequest = {
accounts: accounts, accounts: accounts,
activeUserId: await this.stateService.getUserId(), activeUserId: await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
),
}; };
} }
@@ -564,7 +565,9 @@ export class AppComponent implements OnInit, OnDestroy {
} }
private async logOut(expired: boolean, userId?: string) { private async logOut(expired: boolean, userId?: string) {
const userBeingLoggedOut = await this.stateService.getUserId({ userId: userId }); const userBeingLoggedOut =
(userId as UserId) ??
(await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))));
// Mark account as being cleaned up so that the updateAppMenu logic (executed on syncCompleted) // Mark account as being cleaned up so that the updateAppMenu logic (executed on syncCompleted)
// doesn't attempt to update a user that is being logged out as we will manually // doesn't attempt to update a user that is being logged out as we will manually
@@ -572,9 +575,10 @@ export class AppComponent implements OnInit, OnDestroy {
this.startAccountCleanUp(userBeingLoggedOut); this.startAccountCleanUp(userBeingLoggedOut);
let preLogoutActiveUserId; let preLogoutActiveUserId;
const nextUpAccount = await firstValueFrom(this.accountService.nextUpAccount$);
try { try {
// Provide the userId of the user to upload events for // Provide the userId of the user to upload events for
await this.eventUploadService.uploadEvents(userBeingLoggedOut as UserId); await this.eventUploadService.uploadEvents(userBeingLoggedOut);
await this.syncService.setLastSync(new Date(0), userBeingLoggedOut); await this.syncService.setLastSync(new Date(0), userBeingLoggedOut);
await this.cryptoService.clearKeys(userBeingLoggedOut); await this.cryptoService.clearKeys(userBeingLoggedOut);
await this.cipherService.clear(userBeingLoggedOut); await this.cipherService.clear(userBeingLoggedOut);
@@ -582,22 +586,23 @@ export class AppComponent implements OnInit, OnDestroy {
await this.collectionService.clear(userBeingLoggedOut); await this.collectionService.clear(userBeingLoggedOut);
await this.passwordGenerationService.clear(userBeingLoggedOut); await this.passwordGenerationService.clear(userBeingLoggedOut);
await this.vaultTimeoutSettingsService.clear(userBeingLoggedOut); await this.vaultTimeoutSettingsService.clear(userBeingLoggedOut);
await this.biometricStateService.logout(userBeingLoggedOut as UserId); await this.biometricStateService.logout(userBeingLoggedOut);
await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut as UserId); await this.stateEventRunnerService.handleEvent("logout", userBeingLoggedOut);
preLogoutActiveUserId = this.activeUserId; preLogoutActiveUserId = this.activeUserId;
await this.stateService.clean({ userId: userBeingLoggedOut }); await this.stateService.clean({ userId: userBeingLoggedOut });
await this.accountService.clean(userBeingLoggedOut);
} finally { } finally {
this.finishAccountCleanUp(userBeingLoggedOut); this.finishAccountCleanUp(userBeingLoggedOut);
} }
if (this.activeUserId == null) { if (nextUpAccount == null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // 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 // eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["login"]); this.router.navigate(["login"]);
} else if (preLogoutActiveUserId !== this.activeUserId) { } else if (preLogoutActiveUserId !== nextUpAccount.id) {
this.messagingService.send("switchAccount"); this.messagingService.send("switchAccount", { userId: nextUpAccount.id });
} }
await this.updateAppMenu(); await this.updateAppMenu();
@@ -622,13 +627,13 @@ export class AppComponent implements OnInit, OnDestroy {
return; return;
} }
const now = new Date().getTime(); const now = new Date();
if (this.lastActivity != null && now - this.lastActivity < 250) { if (this.lastActivity != null && now.getTime() - this.lastActivity.getTime() < 250) {
return; return;
} }
this.lastActivity = now; this.lastActivity = now;
await this.stateService.setLastActive(now, { userId: this.activeUserId }); await this.accountService.setAccountActivity(this.activeUserId, now);
// Idle states // Idle states
if (this.isIdle) { if (this.isIdle) {

View File

@@ -1,28 +1,29 @@
<!-- Please remove this disable statement when editing this file! --> <!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable @angular-eslint/template/button-has-type --> <!-- eslint-disable @angular-eslint/template/button-has-type -->
<ng-container *ngIf="view$ | async as view">
<button <button
class="account-switcher" class="account-switcher"
(click)="toggle()" (click)="toggle()"
cdkOverlayOrigin cdkOverlayOrigin
#trigger="cdkOverlayOrigin" #trigger="cdkOverlayOrigin"
[hidden]="!showSwitcher" [hidden]="!view.showSwitcher"
aria-haspopup="dialog" aria-haspopup="dialog"
> >
<ng-container *ngIf="activeAccount?.email != null; else noActiveAccount"> <ng-container *ngIf="view.activeAccount; else noActiveAccount">
<app-avatar <app-avatar
[text]="activeAccount.name" [text]="view.activeAccount.name ?? view.activeAccount.email"
[id]="activeAccount.id" [id]="view.activeAccount.id"
[color]="activeAccount.avatarColor" [color]="view.activeAccount.avatarColor"
[size]="25" [size]="25"
[circle]="true" [circle]="true"
[fontSize]="14" [fontSize]="14"
[dynamic]="true" [dynamic]="true"
*ngIf="activeAccount.email != null" *ngIf="view.activeAccount.email != null"
aria-hidden="true" aria-hidden="true"
></app-avatar> ></app-avatar>
<div class="active-account"> <div class="active-account">
<div>{{ activeAccount.email }}</div> <div>{{ view.activeAccount.email }}</div>
<span>{{ activeAccount.server }}</span> <span>{{ view.activeAccount.server }}</span>
<span class="sr-only">&nbsp;({{ "switchAccount" | i18n }})</span> <span class="sr-only">&nbsp;({{ "switchAccount" | i18n }})</span>
</div> </div>
</ng-container> </ng-container>
@@ -43,7 +44,7 @@
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'" [cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
(backdropClick)="close()" (backdropClick)="close()"
(detach)="close()" (detach)="close()"
[cdkConnectedOverlayOpen]="showSwitcher && isOpen" [cdkConnectedOverlayOpen]="view.showSwitcher && isOpen"
[cdkConnectedOverlayPositions]="overlayPosition" [cdkConnectedOverlayPositions]="overlayPosition"
cdkConnectedOverlayMinWidth="250px" cdkConnectedOverlayMinWidth="250px"
> >
@@ -55,9 +56,9 @@
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
> >
<div class="accounts" *ngIf="numberOfAccounts > 0"> <div class="accounts" *ngIf="view.numberOfAccounts > 0">
<button <button
*ngFor="let account of inactiveAccounts | keyvalue" *ngFor="let account of view.inactiveAccounts | keyvalue"
class="account" class="account"
(click)="switch(account.key)" (click)="switch(account.key)"
> >
@@ -95,16 +96,17 @@
></i> ></i>
</button> </button>
</div> </div>
<ng-container *ngIf="activeAccount?.email != null"> <ng-container *ngIf="view.activeAccount">
<div class="border" *ngIf="numberOfAccounts > 0"></div> <div class="border" *ngIf="view.numberOfAccounts > 0"></div>
<ng-container *ngIf="numberOfAccounts < 4"> <ng-container *ngIf="view.numberOfAccounts < 4">
<button type="button" class="add" (click)="addAccount()"> <button type="button" class="add" (click)="addAccount()">
<i class="bwi bwi-plus" aria-hidden="true"></i> {{ "addAccount" | i18n }} <i class="bwi bwi-plus" aria-hidden="true"></i> {{ "addAccount" | i18n }}
</button> </button>
</ng-container> </ng-container>
<ng-container *ngIf="numberOfAccounts === 4"> <ng-container *ngIf="view.numberOfAccounts === 4">
<span class="accountLimitReached">{{ "accountSwitcherLimitReached" | i18n }} </span> <span class="accountLimitReached">{{ "accountSwitcherLimitReached" | i18n }} </span>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
</ng-template> </ng-template>
</ng-container>

View File

@@ -1,19 +1,17 @@
import { animate, state, style, transition, trigger } from "@angular/animations"; import { animate, state, style, transition, trigger } from "@angular/animations";
import { ConnectedPosition } from "@angular/cdk/overlay"; import { ConnectedPosition } from "@angular/cdk/overlay";
import { Component, OnDestroy, OnInit } from "@angular/core"; import { Component } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { concatMap, firstValueFrom, Subject, takeUntil } from "rxjs"; import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common"; import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { Account } from "@bitwarden/common/platform/models/domain/account";
import { UserId } from "@bitwarden/common/types/guid"; import { UserId } from "@bitwarden/common/types/guid";
type ActiveAccount = { type ActiveAccount = {
@@ -52,12 +50,18 @@ type InactiveAccount = ActiveAccount & {
]), ]),
], ],
}) })
export class AccountSwitcherComponent implements OnInit, OnDestroy { export class AccountSwitcherComponent {
activeAccount?: ActiveAccount; activeAccount$: Observable<ActiveAccount | null>;
inactiveAccounts: { [userId: string]: InactiveAccount } = {}; inactiveAccounts$: Observable<{ [userId: string]: InactiveAccount }>;
authStatus = AuthenticationStatus; authStatus = AuthenticationStatus;
view$: Observable<{
activeAccount: ActiveAccount | null;
inactiveAccounts: { [userId: string]: InactiveAccount };
numberOfAccounts: number;
showSwitcher: boolean;
}>;
isOpen = false; isOpen = false;
overlayPosition: ConnectedPosition[] = [ overlayPosition: ConnectedPosition[] = [
{ {
@@ -68,21 +72,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
}, },
]; ];
private destroy$ = new Subject<void>(); showSwitcher$: Observable<boolean>;
get showSwitcher() { numberOfAccounts$: Observable<number>;
const userIsInAVault = !Utils.isNullOrWhitespace(this.activeAccount?.email);
const userIsAddingAnAdditionalAccount = Object.keys(this.inactiveAccounts).length > 0;
return userIsInAVault || userIsAddingAnAdditionalAccount;
}
get numberOfAccounts() {
if (this.inactiveAccounts == null) {
this.isOpen = false;
return 0;
}
return Object.keys(this.inactiveAccounts).length;
}
constructor( constructor(
private stateService: StateService, private stateService: StateService,
@@ -90,37 +82,65 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
private avatarService: AvatarService, private avatarService: AvatarService,
private messagingService: MessagingService, private messagingService: MessagingService,
private router: Router, private router: Router,
private tokenService: TokenService,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private loginEmailService: LoginEmailServiceAbstraction, private loginEmailService: LoginEmailServiceAbstraction,
) {} private accountService: AccountService,
) {
this.activeAccount$ = this.accountService.activeAccount$.pipe(
switchMap(async (active) => {
if (active == null) {
return null;
}
async ngOnInit(): Promise<void> { return {
this.stateService.accounts$ id: active.id,
.pipe( name: active.name,
concatMap(async (accounts: { [userId: string]: Account }) => { email: active.email,
this.inactiveAccounts = await this.createInactiveAccounts(accounts);
try {
this.activeAccount = {
id: await this.tokenService.getUserId(),
name: (await this.tokenService.getName()) ?? (await this.tokenService.getEmail()),
email: await this.tokenService.getEmail(),
avatarColor: await firstValueFrom(this.avatarService.avatarColor$), avatarColor: await firstValueFrom(this.avatarService.avatarColor$),
server: (await this.environmentService.getEnvironment())?.getHostname(), server: (await this.environmentService.getEnvironment())?.getHostname(),
}; };
} catch {
this.activeAccount = undefined;
}
}), }),
takeUntil(this.destroy$), );
) this.inactiveAccounts$ = combineLatest([
.subscribe(); this.activeAccount$,
} this.accountService.accounts$,
this.authService.authStatuses$,
]).pipe(
switchMap(async ([activeAccount, accounts, accountStatuses]) => {
// Filter out logged out accounts and active account
accounts = Object.fromEntries(
Object.entries(accounts).filter(
([id]: [UserId, AccountInfo]) =>
accountStatuses[id] !== AuthenticationStatus.LoggedOut || id === activeAccount?.id,
),
);
return this.createInactiveAccounts(accounts);
}),
);
this.showSwitcher$ = combineLatest([this.activeAccount$, this.inactiveAccounts$]).pipe(
map(([activeAccount, inactiveAccounts]) => {
const hasActiveUser = activeAccount != null;
const userIsAddingAnAdditionalAccount = Object.keys(inactiveAccounts).length > 0;
return hasActiveUser || userIsAddingAnAdditionalAccount;
}),
);
this.numberOfAccounts$ = this.inactiveAccounts$.pipe(
map((accounts) => Object.keys(accounts).length),
);
ngOnDestroy(): void { this.view$ = combineLatest([
this.destroy$.next(); this.activeAccount$,
this.destroy$.complete(); this.inactiveAccounts$,
this.numberOfAccounts$,
this.showSwitcher$,
]).pipe(
map(([activeAccount, inactiveAccounts, numberOfAccounts, showSwitcher]) => ({
activeAccount,
inactiveAccounts,
numberOfAccounts,
showSwitcher,
})),
);
} }
toggle() { toggle() {
@@ -144,11 +164,13 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
await this.loginEmailService.saveEmailSettings(); await this.loginEmailService.saveEmailSettings();
await this.router.navigate(["/login"]); await this.router.navigate(["/login"]);
await this.stateService.setActiveUser(null); const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
await this.stateService.clearDecryptedData(activeAccount?.id as UserId);
await this.accountService.switchAccount(null);
} }
private async createInactiveAccounts(baseAccounts: { private async createInactiveAccounts(baseAccounts: {
[userId: string]: Account; [userId: string]: AccountInfo;
}): Promise<{ [userId: string]: InactiveAccount }> { }): Promise<{ [userId: string]: InactiveAccount }> {
const inactiveAccounts: { [userId: string]: InactiveAccount } = {}; const inactiveAccounts: { [userId: string]: InactiveAccount } = {};
@@ -159,8 +181,8 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
inactiveAccounts[userId] = { inactiveAccounts[userId] = {
id: userId, id: userId,
name: baseAccounts[userId].profile.name, name: baseAccounts[userId].name,
email: baseAccounts[userId].profile.email, email: baseAccounts[userId].email,
authenticationStatus: await this.authService.getAuthStatus(userId), authenticationStatus: await this.authService.getAuthStatus(userId),
avatarColor: await firstValueFrom(this.avatarService.getUserAvatarColor$(userId as UserId)), avatarColor: await firstValueFrom(this.avatarService.getUserAvatarColor$(userId as UserId)),
server: (await this.environmentService.getEnvironment(userId))?.getHostname(), server: (await this.environmentService.getEnvironment(userId))?.getHostname(),

View File

@@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core";
import { UntypedFormControl } from "@angular/forms"; import { UntypedFormControl } from "@angular/forms";
import { Subscription } from "rxjs"; import { Subscription } from "rxjs";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SearchBarService, SearchBarState } from "./search-bar.service"; import { SearchBarService, SearchBarState } from "./search-bar.service";
@@ -18,7 +18,7 @@ export class SearchComponent implements OnInit, OnDestroy {
constructor( constructor(
private searchBarService: SearchBarService, private searchBarService: SearchBarService,
private stateService: StateService, private accountService: AccountService,
) { ) {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil // eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.searchBarService.state$.subscribe((state) => { this.searchBarService.state$.subscribe((state) => {
@@ -33,7 +33,7 @@ export class SearchComponent implements OnInit, OnDestroy {
ngOnInit() { ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil // eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.activeAccountSubscription = this.stateService.activeAccount$.subscribe((value) => { this.activeAccountSubscription = this.accountService.activeAccount$.subscribe((_) => {
this.searchBarService.setSearchText(""); this.searchBarService.setSearchText("");
this.searchText.patchValue(""); this.searchText.patchValue("");
}); });

View File

@@ -59,7 +59,6 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/ge
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
import { LoginGuard } from "../../auth/guards/login.guard";
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
import { Account } from "../../models/account"; import { Account } from "../../models/account";
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
@@ -102,7 +101,6 @@ const safeProviders: SafeProvider[] = [
safeProvider(InitService), safeProvider(InitService),
safeProvider(NativeMessagingService), safeProvider(NativeMessagingService),
safeProvider(SearchBarService), safeProvider(SearchBarService),
safeProvider(LoginGuard),
safeProvider(DialogService), safeProvider(DialogService),
safeProvider({ safeProvider({
provide: APP_INITIALIZER as SafeInjectionToken<() => void>, provide: APP_INITIALIZER as SafeInjectionToken<() => void>,
@@ -192,6 +190,7 @@ const safeProviders: SafeProvider[] = [
AutofillSettingsServiceAbstraction, AutofillSettingsServiceAbstraction,
VaultTimeoutSettingsService, VaultTimeoutSettingsService,
BiometricStateService, BiometricStateService,
AccountServiceAbstraction,
], ],
}), }),
safeProvider({ safeProvider({

View File

@@ -4,6 +4,7 @@ import { ActivatedRoute } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe"; import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -59,6 +60,10 @@ describe("GeneratorComponent", () => {
provide: CipherService, provide: CipherService,
useValue: mock<CipherService>(), useValue: mock<CipherService>(),
}, },
{
provide: AccountService,
useValue: mock<AccountService>(),
},
], ],
schemas: [NO_ERRORS_SCHEMA], schemas: [NO_ERRORS_SCHEMA],
}).compileComponents(); }).compileComponents();

View File

@@ -4,6 +4,7 @@ import { FormBuilder } from "@angular/forms";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -34,6 +35,7 @@ export class AddEditComponent extends BaseAddEditComponent {
dialogService: DialogService, dialogService: DialogService,
formBuilder: FormBuilder, formBuilder: FormBuilder,
billingAccountProfileStateService: BillingAccountProfileStateService, billingAccountProfileStateService: BillingAccountProfileStateService,
accountService: AccountService,
) { ) {
super( super(
i18nService, i18nService,
@@ -49,6 +51,7 @@ export class AddEditComponent extends BaseAddEditComponent {
dialogService, dialogService,
formBuilder, formBuilder,
billingAccountProfileStateService, billingAccountProfileStateService,
accountService,
); );
} }

View File

@@ -1,29 +0,0 @@
import { Injectable } from "@angular/core";
import { CanActivate } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
const maxAllowedAccounts = 5;
@Injectable()
export class LoginGuard implements CanActivate {
protected homepage = "vault";
constructor(
private stateService: StateService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
) {}
async canActivate() {
const accounts = await firstValueFrom(this.stateService.accounts$);
if (accounts != null && Object.keys(accounts).length >= maxAllowedAccounts) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("accountLimitReached"));
return false;
}
return true;
}
}

View File

@@ -0,0 +1,38 @@
import { inject } from "@angular/core";
import { CanActivateFn } from "@angular/router";
import { Observable, map } from "rxjs";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
const maxAllowedAccounts = 5;
function maxAccountsGuard(): Observable<boolean> {
const authService = inject(AuthService);
const toastService = inject(ToastService);
const i18nService = inject(I18nService);
return authService.authStatuses$.pipe(
map((statuses) =>
Object.values(statuses).filter((status) => status != AuthenticationStatus.LoggedOut),
),
map((accounts) => {
if (accounts != null && Object.keys(accounts).length >= maxAllowedAccounts) {
toastService.showToast({
variant: "error",
title: null,
message: i18nService.t("accountLimitReached"),
});
return false;
}
return true;
}),
);
}
export function maxAccountsGuardFn(): CanActivateFn {
return () => maxAccountsGuard();
}

View File

@@ -13,6 +13,7 @@ import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeou
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
@@ -50,7 +51,7 @@ describe("LockComponent", () => {
let component: LockComponent; let component: LockComponent;
let fixture: ComponentFixture<LockComponent>; let fixture: ComponentFixture<LockComponent>;
let stateServiceMock: MockProxy<StateService>; let stateServiceMock: MockProxy<StateService>;
const biometricStateService = mock<BiometricStateService>(); let biometricStateService: MockProxy<BiometricStateService>;
let messagingServiceMock: MockProxy<MessagingService>; let messagingServiceMock: MockProxy<MessagingService>;
let broadcasterServiceMock: MockProxy<BroadcasterService>; let broadcasterServiceMock: MockProxy<BroadcasterService>;
let platformUtilsServiceMock: MockProxy<PlatformUtilsService>; let platformUtilsServiceMock: MockProxy<PlatformUtilsService>;
@@ -62,7 +63,6 @@ describe("LockComponent", () => {
beforeEach(async () => { beforeEach(async () => {
stateServiceMock = mock<StateService>(); stateServiceMock = mock<StateService>();
stateServiceMock.activeAccount$ = of(null);
messagingServiceMock = mock<MessagingService>(); messagingServiceMock = mock<MessagingService>();
broadcasterServiceMock = mock<BroadcasterService>(); broadcasterServiceMock = mock<BroadcasterService>();
@@ -73,6 +73,7 @@ describe("LockComponent", () => {
mockMasterPasswordService = new FakeMasterPasswordService(); mockMasterPasswordService = new FakeMasterPasswordService();
biometricStateService = mock();
biometricStateService.dismissedRequirePasswordOnStartCallout$ = of(false); biometricStateService.dismissedRequirePasswordOnStartCallout$ = of(false);
biometricStateService.promptAutomatically$ = of(false); biometricStateService.promptAutomatically$ = of(false);
biometricStateService.promptCancelled$ = of(false); biometricStateService.promptCancelled$ = of(false);
@@ -165,6 +166,10 @@ describe("LockComponent", () => {
provide: AccountService, provide: AccountService,
useValue: accountService, useValue: accountService,
}, },
{
provide: AuthService,
useValue: mock(),
},
{ {
provide: KdfConfigService, provide: KdfConfigService,
useValue: mock<KdfConfigService>(), useValue: mock<KdfConfigService>(),

View File

@@ -10,6 +10,7 @@ import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeou
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
@@ -64,6 +65,7 @@ export class LockComponent extends BaseLockComponent {
pinCryptoService: PinCryptoServiceAbstraction, pinCryptoService: PinCryptoServiceAbstraction,
biometricStateService: BiometricStateService, biometricStateService: BiometricStateService,
accountService: AccountService, accountService: AccountService,
authService: AuthService,
kdfConfigService: KdfConfigService, kdfConfigService: KdfConfigService,
) { ) {
super( super(
@@ -89,6 +91,7 @@ export class LockComponent extends BaseLockComponent {
pinCryptoService, pinCryptoService,
biometricStateService, biometricStateService,
accountService, accountService,
authService,
kdfConfigService, kdfConfigService,
); );
} }

View File

@@ -65,9 +65,10 @@ export class Menubar {
isLocked = updateRequest.accounts[updateRequest.activeUserId]?.isLocked ?? true; isLocked = updateRequest.accounts[updateRequest.activeUserId]?.isLocked ?? true;
} }
const isLockable = !isLocked && updateRequest?.accounts[updateRequest.activeUserId]?.isLockable; const isLockable =
!isLocked && updateRequest?.accounts?.[updateRequest.activeUserId]?.isLockable;
const hasMasterPassword = const hasMasterPassword =
updateRequest?.accounts[updateRequest.activeUserId]?.hasMasterPassword ?? false; updateRequest?.accounts?.[updateRequest.activeUserId]?.hasMasterPassword ?? false;
this.items = [ this.items = [
new FileMenu( new FileMenu(

View File

@@ -2,7 +2,7 @@ import { DOCUMENT } from "@angular/common";
import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core";
import { NavigationEnd, Router } from "@angular/router"; import { NavigationEnd, Router } from "@angular/router";
import * as jq from "jquery"; import * as jq from "jquery";
import { Subject, switchMap, takeUntil, timer } from "rxjs"; import { Subject, firstValueFrom, map, switchMap, takeUntil, timer } from "rxjs";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
@@ -10,6 +10,7 @@ import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction"; import { PaymentMethodWarningsServiceAbstraction as PaymentMethodWarningService } from "@bitwarden/common/billing/abstractions/payment-method-warnings-service.abstraction";
@@ -51,7 +52,7 @@ const PaymentMethodWarningsRefresh = 60000; // 1 Minute
templateUrl: "app.component.html", templateUrl: "app.component.html",
}) })
export class AppComponent implements OnDestroy, OnInit { export class AppComponent implements OnDestroy, OnInit {
private lastActivity: number = null; private lastActivity: Date = null;
private idleTimer: number = null; private idleTimer: number = null;
private isIdle = false; private isIdle = false;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -86,6 +87,7 @@ export class AppComponent implements OnDestroy, OnInit {
private stateEventRunnerService: StateEventRunnerService, private stateEventRunnerService: StateEventRunnerService,
private paymentMethodWarningService: PaymentMethodWarningService, private paymentMethodWarningService: PaymentMethodWarningService,
private organizationService: InternalOrganizationServiceAbstraction, private organizationService: InternalOrganizationServiceAbstraction,
private accountService: AccountService,
) {} ) {}
ngOnInit() { ngOnInit() {
@@ -298,15 +300,16 @@ export class AppComponent implements OnDestroy, OnInit {
} }
private async recordActivity() { private async recordActivity() {
const now = new Date().getTime(); const activeUserId = await firstValueFrom(
if (this.lastActivity != null && now - this.lastActivity < 250) { this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const now = new Date();
if (this.lastActivity != null && now.getTime() - this.lastActivity.getTime() < 250) {
return; return;
} }
this.lastActivity = now; this.lastActivity = now;
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. await this.accountService.setAccountActivity(activeUserId, now);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.stateService.setLastActive(now);
// Idle states // Idle states
if (this.isIdle) { if (this.isIdle) {
this.isIdle = false; this.isIdle = false;

View File

@@ -58,7 +58,7 @@
[bitMenuTriggerFor]="accountMenu" [bitMenuTriggerFor]="accountMenu"
class="tw-border-0 tw-bg-transparent tw-p-0" class="tw-border-0 tw-bg-transparent tw-p-0"
> >
<dynamic-avatar [id]="account.userId" [text]="account | userName"></dynamic-avatar> <dynamic-avatar [id]="account.id" [text]="account | userName"></dynamic-avatar>
</button> </button>
<bit-menu #accountMenu> <bit-menu #accountMenu>
@@ -67,7 +67,7 @@
class="tw-flex tw-items-center tw-px-4 tw-py-1 tw-leading-tight tw-text-info" class="tw-flex tw-items-center tw-px-4 tw-py-1 tw-leading-tight tw-text-info"
appStopProp appStopProp
> >
<dynamic-avatar [id]="account.userId" [text]="account | userName"></dynamic-avatar> <dynamic-avatar [id]="account.id" [text]="account | userName"></dynamic-avatar>
<div class="tw-ml-2 tw-block tw-overflow-hidden tw-whitespace-nowrap"> <div class="tw-ml-2 tw-block tw-overflow-hidden tw-whitespace-nowrap">
<span>{{ "loggedInAs" | i18n }}</span> <span>{{ "loggedInAs" | i18n }}</span>
<small class="tw-block tw-overflow-hidden tw-whitespace-nowrap tw-text-muted"> <small class="tw-block tw-overflow-hidden tw-whitespace-nowrap tw-text-muted">

View File

@@ -1,16 +1,17 @@
import { Component, Input } from "@angular/core"; import { Component, Input } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; import { ActivatedRoute } from "@angular/router";
import { combineLatest, map, Observable } from "rxjs"; import { map, Observable } from "rxjs";
import { User } from "@bitwarden/angular/pipes/user-name.pipe";
import { UnassignedItemsBannerService } from "@bitwarden/angular/services/unassigned-items-banner.service"; import { UnassignedItemsBannerService } from "@bitwarden/angular/services/unassigned-items-banner.service";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { UserId } from "@bitwarden/common/types/guid";
import { AccountProfile } from "@bitwarden/common/platform/models/domain/account";
@Component({ @Component({
selector: "app-header", selector: "app-header",
@@ -28,7 +29,7 @@ export class WebHeaderComponent {
@Input() icon: string; @Input() icon: string;
protected routeData$: Observable<{ titleId: string }>; protected routeData$: Observable<{ titleId: string }>;
protected account$: Observable<AccountProfile>; protected account$: Observable<User & { id: UserId }>;
protected canLock$: Observable<boolean>; protected canLock$: Observable<boolean>;
protected selfHosted: boolean; protected selfHosted: boolean;
protected hostname = location.hostname; protected hostname = location.hostname;
@@ -38,12 +39,12 @@ export class WebHeaderComponent {
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private stateService: StateService,
private platformUtilsService: PlatformUtilsService, private platformUtilsService: PlatformUtilsService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private messagingService: MessagingService, private messagingService: MessagingService,
protected unassignedItemsBannerService: UnassignedItemsBannerService, protected unassignedItemsBannerService: UnassignedItemsBannerService,
private configService: ConfigService, private configService: ConfigService,
private accountService: AccountService,
) { ) {
this.routeData$ = this.route.data.pipe( this.routeData$ = this.route.data.pipe(
map((params) => { map((params) => {
@@ -55,14 +56,7 @@ export class WebHeaderComponent {
this.selfHosted = this.platformUtilsService.isSelfHost(); this.selfHosted = this.platformUtilsService.isSelfHost();
this.account$ = combineLatest([ this.account$ = this.accountService.activeAccount$;
this.stateService.activeAccount$,
this.stateService.accounts$,
]).pipe(
map(([activeAccount, accounts]) => {
return accounts[activeAccount]?.profile;
}),
);
this.canLock$ = this.vaultTimeoutSettingsService this.canLock$ = this.vaultTimeoutSettingsService
.availableVaultTimeoutActions$() .availableVaultTimeoutActions$()
.pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock))); .pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock)));

View File

@@ -5,6 +5,7 @@ import { FormBuilder } from "@angular/forms";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -40,6 +41,7 @@ export class AddEditComponent extends BaseAddEditComponent {
billingAccountProfileStateService: BillingAccountProfileStateService, billingAccountProfileStateService: BillingAccountProfileStateService,
protected dialogRef: DialogRef, protected dialogRef: DialogRef,
@Inject(DIALOG_DATA) params: { sendId: string }, @Inject(DIALOG_DATA) params: { sendId: string },
accountService: AccountService,
) { ) {
super( super(
i18nService, i18nService,
@@ -55,6 +57,7 @@ export class AddEditComponent extends BaseAddEditComponent {
dialogService, dialogService,
formBuilder, formBuilder,
billingAccountProfileStateService, billingAccountProfileStateService,
accountService,
); );
this.sendId = params.sendId; this.sendId = params.sendId;

View File

@@ -55,7 +55,6 @@ export default {
{ {
provide: StateService, provide: StateService,
useValue: { useValue: {
activeAccount$: new BehaviorSubject("1").asObservable(),
accounts$: new BehaviorSubject({ "1": { profile: { name: "Foo" } } }).asObservable(), accounts$: new BehaviorSubject({ "1": { profile: { name: "Foo" } } }).asObservable(),
async getShowFavicon() { async getShowFavicon() {
return true; return true;

View File

@@ -1,4 +1,4 @@
<form [formGroup]="formGroup" [bitSubmit]="submit"> <form [formGroup]="formGroup" [bitSubmit]="submit" *ngIf="!loading; else spinner">
<div class="tw-w-2/5"> <div class="tw-w-2/5">
<p class="tw-mt-8" *ngIf="!loading"> <p class="tw-mt-8" *ngIf="!loading">
{{ "projectPeopleDescription" | i18n }} {{ "projectPeopleDescription" | i18n }}
@@ -19,3 +19,9 @@
</button> </button>
</div> </div>
</form> </form>
<ng-template #spinner>
<div class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
</ng-template>

View File

@@ -1,7 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core"; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms"; import { FormControl, FormGroup } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, Subject, switchMap, takeUntil, catchError, EMPTY } from "rxjs"; import { combineLatest, Subject, switchMap, takeUntil, catchError } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -37,11 +37,9 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
return convertToAccessPolicyItemViews(policies); return convertToAccessPolicyItemViews(policies);
}), }),
), ),
catchError(() => { catchError(async () => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. await this.router.navigate(["/sm", this.organizationId, "projects"]);
// eslint-disable-next-line @typescript-eslint/no-floating-promises return [];
this.router.navigate(["/sm", this.organizationId, "projects"]);
return EMPTY;
}), }),
); );
@@ -99,17 +97,20 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
if (this.formGroup.invalid) { if (this.formGroup.invalid) {
return; return;
} }
const formValues = this.formGroup.value.accessPolicies;
this.formGroup.disable();
const showAccessRemovalWarning = const showAccessRemovalWarning =
await this.accessPolicySelectorService.showAccessRemovalWarning( await this.accessPolicySelectorService.showAccessRemovalWarning(
this.organizationId, this.organizationId,
this.formGroup.value.accessPolicies, formValues,
); );
if (showAccessRemovalWarning) { if (showAccessRemovalWarning) {
const confirmed = await this.showWarning(); const confirmed = await this.showWarning();
if (!confirmed) { if (!confirmed) {
this.setSelected(this.currentAccessPolicies); this.setSelected(this.currentAccessPolicies);
this.formGroup.enable();
return; return;
} }
} }
@@ -117,7 +118,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
try { try {
const projectPeopleView = convertToProjectPeopleAccessPoliciesView( const projectPeopleView = convertToProjectPeopleAccessPoliciesView(
this.projectId, this.projectId,
this.formGroup.value.accessPolicies, formValues,
); );
const peoplePoliciesViews = await this.accessPolicyService.putProjectPeopleAccessPolicies( const peoplePoliciesViews = await this.accessPolicyService.putProjectPeopleAccessPolicies(
this.projectId, this.projectId,
@@ -126,9 +127,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews); this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews);
if (showAccessRemovalWarning) { if (showAccessRemovalWarning) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. await this.router.navigate(["sm", this.organizationId, "projects"]);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["sm", this.organizationId, "projects"]);
} }
this.platformUtilsService.showToast( this.platformUtilsService.showToast(
"success", "success",
@@ -139,6 +138,7 @@ export class ProjectPeopleComponent implements OnInit, OnDestroy {
this.validationService.showError(e); this.validationService.showError(e);
this.setSelected(this.currentAccessPolicies); this.setSelected(this.currentAccessPolicies);
} }
this.formGroup.enable();
}; };
private setSelected(policiesToSelect: ApItemViewType[]) { private setSelected(policiesToSelect: ApItemViewType[]) {

View File

@@ -1,4 +1,4 @@
<form [formGroup]="formGroup" [bitSubmit]="submit"> <form [formGroup]="formGroup" [bitSubmit]="submit" *ngIf="!loading; else spinner">
<div class="tw-w-2/5"> <div class="tw-w-2/5">
<p class="tw-mt-8" *ngIf="!loading"> <p class="tw-mt-8" *ngIf="!loading">
{{ "machineAccountPeopleDescription" | i18n }} {{ "machineAccountPeopleDescription" | i18n }}
@@ -20,3 +20,9 @@
</button> </button>
</div> </div>
</form> </form>
<ng-template #spinner>
<div class="tw-items-center tw-justify-center tw-pt-64 tw-text-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x"></i>
</div>
</ng-template>

View File

@@ -1,7 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core"; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
import { FormControl, FormGroup } from "@angular/forms"; import { FormControl, FormGroup } from "@angular/forms";
import { ActivatedRoute, Router } from "@angular/router"; import { ActivatedRoute, Router } from "@angular/router";
import { catchError, combineLatest, EMPTY, Subject, switchMap, takeUntil } from "rxjs"; import { combineLatest, Subject, switchMap, takeUntil } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -40,12 +40,6 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
return convertToAccessPolicyItemViews(policies); return convertToAccessPolicyItemViews(policies);
}), }),
), ),
catchError(() => {
// 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.router.navigate(["/sm", this.organizationId, "machine-accounts"]);
return EMPTY;
}),
); );
private potentialGrantees$ = combineLatest([this.route.params]).pipe( private potentialGrantees$ = combineLatest([this.route.params]).pipe(
@@ -101,29 +95,32 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
if (this.isFormInvalid()) { if (this.isFormInvalid()) {
return; return;
} }
const formValues = this.formGroup.value.accessPolicies;
this.formGroup.disable();
const showAccessRemovalWarning = const showAccessRemovalWarning =
await this.accessPolicySelectorService.showAccessRemovalWarning( await this.accessPolicySelectorService.showAccessRemovalWarning(
this.organizationId, this.organizationId,
this.formGroup.value.accessPolicies, formValues,
); );
if ( if (
await this.handleAccessRemovalWarning(showAccessRemovalWarning, this.currentAccessPolicies) await this.handleAccessRemovalWarning(showAccessRemovalWarning, this.currentAccessPolicies)
) { ) {
this.formGroup.enable();
return; return;
} }
try { try {
const peoplePoliciesViews = await this.updateServiceAccountPeopleAccessPolicies( const peoplePoliciesViews = await this.updateServiceAccountPeopleAccessPolicies(
this.serviceAccountId, this.serviceAccountId,
this.formGroup.value.accessPolicies, formValues,
); );
await this.handleAccessTokenAvailableWarning( await this.handleAccessTokenAvailableWarning(
showAccessRemovalWarning, showAccessRemovalWarning,
this.currentAccessPolicies, this.currentAccessPolicies,
this.formGroup.value.accessPolicies, formValues,
); );
this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews); this.currentAccessPolicies = convertToAccessPolicyItemViews(peoplePoliciesViews);
@@ -137,6 +134,7 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
this.validationService.showError(e); this.validationService.showError(e);
this.setSelected(this.currentAccessPolicies); this.setSelected(this.currentAccessPolicies);
} }
this.formGroup.enable();
}; };
private setSelected(policiesToSelect: ApItemViewType[]) { private setSelected(policiesToSelect: ApItemViewType[]) {
@@ -198,9 +196,7 @@ export class ServiceAccountPeopleComponent implements OnInit, OnDestroy {
selectedPolicies: ApItemValueType[], selectedPolicies: ApItemValueType[],
): Promise<void> { ): Promise<void> {
if (showAccessRemovalWarning) { if (showAccessRemovalWarning) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. await this.router.navigate(["sm", this.organizationId, "machine-accounts"]);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["sm", this.organizationId, "machine-accounts"]);
} else if ( } else if (
this.accessPolicySelectorService.isAccessRemoval(currentAccessPolicies, selectedPolicies) this.accessPolicySelectorService.isAccessRemoval(currentAccessPolicies, selectedPolicies)
) { ) {

View File

@@ -55,6 +55,7 @@
bitIconButton="bwi-close" bitIconButton="bwi-close"
buttonType="main" buttonType="main"
size="default" size="default"
[disabled]="disabled"
[attr.title]="'remove' | i18n" [attr.title]="'remove' | i18n"
[attr.aria-label]="'remove' | i18n" [attr.aria-label]="'remove' | i18n"
(click)="selectionList.deselectItem(item.id); handleBlur()" (click)="selectionList.deselectItem(item.id); handleBlur()"
@@ -84,7 +85,14 @@
</bit-form-field> </bit-form-field>
<div class="tw-ml-3 tw-mt-7 tw-shrink-0"> <div class="tw-ml-3 tw-mt-7 tw-shrink-0">
<button type="button" bitButton buttonType="secondary" (click)="addButton()"> <button
type="button"
bitButton
buttonType="secondary"
[loading]="loading"
[disabled]="disabled"
(click)="addButton()"
>
{{ "add" | i18n }} {{ "add" | i18n }}
</button> </button>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core";
import { Router } from "@angular/router"; import { Router } from "@angular/router";
import { firstValueFrom, Subject } from "rxjs"; import { firstValueFrom, Subject } from "rxjs";
import { concatMap, take, takeUntil } from "rxjs/operators"; import { concatMap, map, take, takeUntil } from "rxjs/operators";
import { PinCryptoServiceAbstraction } from "@bitwarden/auth/common"; import { PinCryptoServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -11,10 +11,12 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service"; import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
@@ -30,6 +32,7 @@ import { BiometricStateService } from "@bitwarden/common/platform/biometrics/bio
import { HashPurpose, KeySuffixOptions } from "@bitwarden/common/platform/enums"; import { HashPurpose, KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service"; import { PinLockType } from "@bitwarden/common/services/vault-timeout/vault-timeout-settings.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key"; import { UserKey } from "@bitwarden/common/types/key";
import { DialogService } from "@bitwarden/components"; import { DialogService } from "@bitwarden/components";
@@ -46,6 +49,7 @@ export class LockComponent implements OnInit, OnDestroy {
supportsBiometric: boolean; supportsBiometric: boolean;
biometricLock: boolean; biometricLock: boolean;
private activeUserId: UserId;
protected successRoute = "vault"; protected successRoute = "vault";
protected forcePasswordResetRoute = "update-temp-password"; protected forcePasswordResetRoute = "update-temp-password";
protected onSuccessfulSubmit: () => Promise<void>; protected onSuccessfulSubmit: () => Promise<void>;
@@ -80,14 +84,16 @@ export class LockComponent implements OnInit, OnDestroy {
protected pinCryptoService: PinCryptoServiceAbstraction, protected pinCryptoService: PinCryptoServiceAbstraction,
protected biometricStateService: BiometricStateService, protected biometricStateService: BiometricStateService,
protected accountService: AccountService, protected accountService: AccountService,
protected authService: AuthService,
protected kdfConfigService: KdfConfigService, protected kdfConfigService: KdfConfigService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
this.stateService.activeAccount$ this.accountService.activeAccount$
.pipe( .pipe(
concatMap(async () => { concatMap(async (account) => {
await this.load(); this.activeUserId = account?.id;
await this.load(account?.id);
}), }),
takeUntil(this.destroy$), takeUntil(this.destroy$),
) )
@@ -116,7 +122,7 @@ export class LockComponent implements OnInit, OnDestroy {
}); });
if (confirmed) { if (confirmed) {
this.messagingService.send("logout"); this.messagingService.send("logout", { userId: this.activeUserId });
} }
} }
@@ -321,23 +327,35 @@ export class LockComponent implements OnInit, OnDestroy {
} }
} }
private async load() { private async load(userId: UserId) {
// TODO: Investigate PM-3515 // TODO: Investigate PM-3515
// The loading of the lock component works as follows: // The loading of the lock component works as follows:
// 1. First, is locking a valid timeout action? If not, we will log the user out. // 1. If the user is unlocked, we're here in error so we navigate to the home page
// 2. If locking IS a valid timeout action, we proceed to show the user the lock screen. // 2. First, is locking a valid timeout action? If not, we will log the user out.
// 3. If locking IS a valid timeout action, we proceed to show the user the lock screen.
// The user will be able to unlock as follows: // The user will be able to unlock as follows:
// - If they have a PIN set, they will be presented with the PIN input // - If they have a PIN set, they will be presented with the PIN input
// - If they have a master password and no PIN, they will be presented with the master password input // - If they have a master password and no PIN, they will be presented with the master password input
// - If they have biometrics enabled, they will be presented with the biometric prompt // - If they have biometrics enabled, they will be presented with the biometric prompt
const isUnlocked = await firstValueFrom(
this.authService
.authStatusFor$(userId)
.pipe(map((status) => status === AuthenticationStatus.Unlocked)),
);
if (isUnlocked) {
// navigate to home
await this.router.navigate(["/"]);
return;
}
const availableVaultTimeoutActions = await firstValueFrom( const availableVaultTimeoutActions = await firstValueFrom(
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(), this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
); );
const supportsLock = availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock); const supportsLock = availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock);
if (!supportsLock) { if (!supportsLock) {
return await this.vaultTimeoutService.logOut(); return await this.vaultTimeoutService.logOut(userId);
} }
this.pinStatus = await this.vaultTimeoutSettingsService.isPinLockSet(); this.pinStatus = await this.vaultTimeoutSettingsService.isPinLockSet();

View File

@@ -1,6 +1,6 @@
import { Pipe, PipeTransform } from "@angular/core"; import { Pipe, PipeTransform } from "@angular/core";
interface User { export interface User {
name?: string; name?: string;
email?: string; email?: string;
} }

View File

@@ -5,6 +5,7 @@ import { Subject, firstValueFrom, takeUntil, map, BehaviorSubject, concatMap } f
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -118,6 +119,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected dialogService: DialogService, protected dialogService: DialogService,
protected formBuilder: FormBuilder, protected formBuilder: FormBuilder,
protected billingAccountProfileStateService: BillingAccountProfileStateService, protected billingAccountProfileStateService: BillingAccountProfileStateService,
protected accountService: AccountService,
) { ) {
this.typeOptions = [ this.typeOptions = [
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true }, { name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
@@ -215,7 +217,9 @@ export class AddEditComponent implements OnInit, OnDestroy {
} }
async load() { async load() {
this.emailVerified = await this.stateService.getEmailVerified(); this.emailVerified = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.emailVerified ?? false)),
);
this.type = !this.canAccessPremium || !this.emailVerified ? SendType.Text : SendType.File; this.type = !this.canAccessPremium || !this.emailVerified ? SendType.Text : SendType.File;
if (this.send == null) { if (this.send == null) {

View File

@@ -128,6 +128,7 @@ describe("AuthRequestLoginStrategy", () => {
masterPasswordService.masterKeySubject.next(masterKey); masterPasswordService.masterKeySubject.next(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
tokenService.decodeAccessToken.mockResolvedValue({ sub: mockUserId });
await authRequestLoginStrategy.logIn(credentials); await authRequestLoginStrategy.logIn(credentials);

View File

@@ -218,7 +218,7 @@ describe("LoginStrategy", () => {
expect(messagingService.send).toHaveBeenCalledWith("loggedIn"); expect(messagingService.send).toHaveBeenCalledWith("loggedIn");
}); });
it("throws if active account isn't found after being initialized", async () => { it("throws if new account isn't active after being initialized", async () => {
const idTokenResponse = identityTokenResponseFactory(); const idTokenResponse = identityTokenResponseFactory();
apiService.postIdentityToken.mockResolvedValue(idTokenResponse); apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
@@ -228,7 +228,8 @@ describe("LoginStrategy", () => {
stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction); stateService.getVaultTimeoutAction.mockResolvedValue(mockVaultTimeoutAction);
stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout); stateService.getVaultTimeout.mockResolvedValue(mockVaultTimeout);
accountService.activeAccountSubject.next(null); accountService.switchAccount = jest.fn(); // block internal switch to new account
accountService.activeAccountSubject.next(null); // simulate no active account
await expect(async () => await passwordLoginStrategy.logIn(credentials)).rejects.toThrow(); await expect(async () => await passwordLoginStrategy.logIn(credentials)).rejects.toThrow();
}); });

View File

@@ -169,6 +169,12 @@ export abstract class LoginStrategy {
const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId }); const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction({ userId });
const vaultTimeout = await this.stateService.getVaultTimeout({ userId }); const vaultTimeout = await this.stateService.getVaultTimeout({ userId });
await this.accountService.addAccount(userId, {
name: accountInformation.name,
email: accountInformation.email,
emailVerified: accountInformation.email_verified,
});
// set access token and refresh token before account initialization so authN status can be accurate // set access token and refresh token before account initialization so authN status can be accurate
// User id will be derived from the access token. // User id will be derived from the access token.
await this.tokenService.setTokens( await this.tokenService.setTokens(
@@ -178,6 +184,8 @@ export abstract class LoginStrategy {
tokenResponse.refreshToken, // Note: CLI login via API key sends undefined for refresh token. tokenResponse.refreshToken, // Note: CLI login via API key sends undefined for refresh token.
); );
await this.accountService.switchAccount(userId);
await this.stateService.addAccount( await this.stateService.addAccount(
new Account({ new Account({
profile: { profile: {

View File

@@ -164,6 +164,7 @@ describe("PasswordLoginStrategy", () => {
masterPasswordService.masterKeySubject.next(masterKey); masterPasswordService.masterKeySubject.next(masterKey);
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
await passwordLoginStrategy.logIn(credentials); await passwordLoginStrategy.logIn(credentials);
@@ -199,6 +200,7 @@ describe("PasswordLoginStrategy", () => {
it("forces the user to update their master password on successful login when it does not meet master password policy requirements", async () => { it("forces the user to update their master password on successful login when it does not meet master password policy requirements", async () => {
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any); passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
policyService.evaluateMasterPassword.mockReturnValue(false); policyService.evaluateMasterPassword.mockReturnValue(false);
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
const result = await passwordLoginStrategy.logIn(credentials); const result = await passwordLoginStrategy.logIn(credentials);
@@ -213,6 +215,7 @@ describe("PasswordLoginStrategy", () => {
it("forces the user to update their master password on successful 2FA login when it does not meet master password policy requirements", async () => { it("forces the user to update their master password on successful 2FA login when it does not meet master password policy requirements", async () => {
passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any); passwordStrengthService.getPasswordStrength.mockReturnValue({ score: 0 } as any);
policyService.evaluateMasterPassword.mockReturnValue(false); policyService.evaluateMasterPassword.mockReturnValue(false);
tokenService.decodeAccessToken.mockResolvedValue({ sub: userId });
const token2FAResponse = new IdentityTwoFactorResponse({ const token2FAResponse = new IdentityTwoFactorResponse({
TwoFactorProviders: ["0"], TwoFactorProviders: ["0"],

View File

@@ -65,6 +65,7 @@ describe("UserDecryptionOptionsService", () => {
await fakeAccountService.addAccount(givenUser, { await fakeAccountService.addAccount(givenUser, {
name: "Test User 1", name: "Test User 1",
email: "test1@email.com", email: "test1@email.com",
emailVerified: false,
}); });
await fakeStateProvider.setUserState( await fakeStateProvider.setUserState(
USER_DECRYPTION_OPTIONS, USER_DECRYPTION_OPTIONS,

View File

@@ -1,5 +1,5 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { ReplaySubject } from "rxjs"; import { ReplaySubject, combineLatest, map } from "rxjs";
import { AccountInfo, AccountService } from "../src/auth/abstractions/account.service"; import { AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
import { UserId } from "../src/types/guid"; import { UserId } from "../src/types/guid";
@@ -7,15 +7,20 @@ import { UserId } from "../src/types/guid";
export function mockAccountServiceWith( export function mockAccountServiceWith(
userId: UserId, userId: UserId,
info: Partial<AccountInfo> = {}, info: Partial<AccountInfo> = {},
activity: Record<UserId, Date> = {},
): FakeAccountService { ): FakeAccountService {
const fullInfo: AccountInfo = { const fullInfo: AccountInfo = {
...info, ...info,
...{ ...{
name: "name", name: "name",
email: "email", email: "email",
emailVerified: true,
}, },
}; };
const service = new FakeAccountService({ [userId]: fullInfo });
const fullActivity = { [userId]: new Date(), ...activity };
const service = new FakeAccountService({ [userId]: fullInfo }, fullActivity);
service.activeAccountSubject.next({ id: userId, ...fullInfo }); service.activeAccountSubject.next({ id: userId, ...fullInfo });
return service; return service;
} }
@@ -26,17 +31,46 @@ export class FakeAccountService implements AccountService {
accountsSubject = new ReplaySubject<Record<UserId, AccountInfo>>(1); accountsSubject = new ReplaySubject<Record<UserId, AccountInfo>>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class // eslint-disable-next-line rxjs/no-exposed-subjects -- test class
activeAccountSubject = new ReplaySubject<{ id: UserId } & AccountInfo>(1); activeAccountSubject = new ReplaySubject<{ id: UserId } & AccountInfo>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
private _activeUserId: UserId; private _activeUserId: UserId;
get activeUserId() { get activeUserId() {
return this._activeUserId; return this._activeUserId;
} }
accounts$ = this.accountsSubject.asObservable(); accounts$ = this.accountsSubject.asObservable();
activeAccount$ = this.activeAccountSubject.asObservable(); activeAccount$ = this.activeAccountSubject.asObservable();
accountActivity$ = this.accountActivitySubject.asObservable();
get sortedUserIds$() {
return this.accountActivity$.pipe(
map((activity) => {
return Object.entries(activity)
.map(([userId, lastActive]: [UserId, Date]) => ({ userId, lastActive }))
.sort((a, b) => a.lastActive.getTime() - b.lastActive.getTime())
.map((a) => a.userId);
}),
);
}
get nextUpAccount$() {
return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
map(([accounts, activeAccount, sortedUserIds]) => {
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);
return nextId ? { id: nextId, ...accounts[nextId] } : null;
}),
);
}
constructor(initialData: Record<UserId, AccountInfo>) { constructor(initialData: Record<UserId, AccountInfo>, accountActivity?: Record<UserId, Date>) {
this.accountsSubject.next(initialData); this.accountsSubject.next(initialData);
this.activeAccountSubject.subscribe((data) => (this._activeUserId = data?.id)); this.activeAccountSubject.subscribe((data) => (this._activeUserId = data?.id));
this.activeAccountSubject.next(null); this.activeAccountSubject.next(null);
this.accountActivitySubject.next(accountActivity);
}
setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {
this.accountActivitySubject.next({
...this.accountActivitySubject["_buffer"][0],
[userId]: lastActivity,
});
return this.mock.setAccountActivity(userId, lastActivity);
} }
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> { async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
@@ -53,10 +87,27 @@ export class FakeAccountService implements AccountService {
await this.mock.setAccountEmail(userId, email); await this.mock.setAccountEmail(userId, email);
} }
async setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void> {
await this.mock.setAccountEmailVerified(userId, emailVerified);
}
async switchAccount(userId: UserId): Promise<void> { async switchAccount(userId: UserId): Promise<void> {
const next = const next =
userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] }; userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] };
this.activeAccountSubject.next(next); this.activeAccountSubject.next(next);
await this.mock.switchAccount(userId); await this.mock.switchAccount(userId);
} }
async clean(userId: UserId): Promise<void> {
const current = this.accountsSubject["_buffer"][0] ?? {};
const updated = { ...current, [userId]: loggedOutInfo };
this.accountsSubject.next(updated);
await this.mock.clean(userId);
} }
}
const loggedOutInfo: AccountInfo = {
name: undefined,
email: "",
emailVerified: false,
};

View File

@@ -8,18 +8,44 @@ import { UserId } from "../../types/guid";
*/ */
export type AccountInfo = { export type AccountInfo = {
email: string; email: string;
emailVerified: boolean;
name: string | undefined; name: string | undefined;
}; };
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) { export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
return a?.email === b?.email && a?.name === b?.name; if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof AccountInfo>;
for (const key of keys) {
if (a[key] !== b[key]) {
return false;
}
}
return true;
} }
export abstract class AccountService { export abstract class AccountService {
accounts$: Observable<Record<UserId, AccountInfo>>; accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>; activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;
/**
* Observable of the last activity time for each account.
*/
accountActivity$: Observable<Record<UserId, Date>>;
/** Account list in order of descending recency */
sortedUserIds$: Observable<UserId[]>;
/** Next account that is not the current active account */
nextUpAccount$: Observable<{ id: UserId } & AccountInfo>;
/** /**
* Updates the `accounts$` observable with the new account data. * Updates the `accounts$` observable with the new account data.
*
* @note Also sets the last active date of the account to `now`.
* @param userId * @param userId
* @param accountData * @param accountData
*/ */
@@ -36,11 +62,30 @@ export abstract class AccountService {
* @param email * @param email
*/ */
abstract setAccountEmail(userId: UserId, email: string): Promise<void>; abstract setAccountEmail(userId: UserId, email: string): Promise<void>;
/**
* updates the `accounts$` observable with the new email verification status for the account.
* @param userId
* @param emailVerified
*/
abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>;
/** /**
* Updates the `activeAccount$` observable with the new active account. * Updates the `activeAccount$` observable with the new active account.
* @param userId * @param userId
*/ */
abstract switchAccount(userId: UserId): Promise<void>; abstract switchAccount(userId: UserId): Promise<void>;
/**
* Cleans personal information for the given account from the `accounts$` observable. Does not remove the userId from the observable.
*
* @note Also sets the last active date of the account to `null`.
* @param userId
*/
abstract clean(userId: UserId): Promise<void>;
/**
* Updates the given user's last activity time.
* @param userId
* @param lastActivity
*/
abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>;
} }
export abstract class InternalAccountService extends AccountService { export abstract class InternalAccountService extends AccountService {

View File

@@ -1,3 +1,8 @@
/**
* need to update test environment so structuredClone works appropriately
* @jest-environment ../../libs/shared/test.environment.ts
*/
import { MockProxy, mock } from "jest-mock-extended"; import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
@@ -6,15 +11,57 @@ import { FakeGlobalStateProvider } from "../../../spec/fake-state-provider";
import { trackEmissions } from "../../../spec/utils"; import { trackEmissions } from "../../../spec/utils";
import { LogService } from "../../platform/abstractions/log.service"; import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service"; import { MessagingService } from "../../platform/abstractions/messaging.service";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { AccountInfo } from "../abstractions/account.service"; import { AccountInfo, accountInfoEqual } from "../abstractions/account.service";
import { import {
ACCOUNT_ACCOUNTS, ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID, ACCOUNT_ACTIVE_ACCOUNT_ID,
ACCOUNT_ACTIVITY,
AccountServiceImplementation, AccountServiceImplementation,
} from "./account.service"; } from "./account.service";
describe("accountInfoEqual", () => {
const accountInfo: AccountInfo = { name: "name", email: "email", emailVerified: true };
it("compares nulls", () => {
expect(accountInfoEqual(null, null)).toBe(true);
expect(accountInfoEqual(null, accountInfo)).toBe(false);
expect(accountInfoEqual(accountInfo, null)).toBe(false);
});
it("compares all keys, not just those defined in AccountInfo", () => {
const different = { ...accountInfo, extra: "extra" };
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares name", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, name: "name2" };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares email", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, email: "email2" };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares emailVerified", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, emailVerified: false };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
});
describe("accountService", () => { describe("accountService", () => {
let messagingService: MockProxy<MessagingService>; let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>; let logService: MockProxy<LogService>;
@@ -22,8 +69,8 @@ describe("accountService", () => {
let sut: AccountServiceImplementation; let sut: AccountServiceImplementation;
let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>; let accountsState: FakeGlobalState<Record<UserId, AccountInfo>>;
let activeAccountIdState: FakeGlobalState<UserId>; let activeAccountIdState: FakeGlobalState<UserId>;
const userId = "userId" as UserId; const userId = Utils.newGuid() as UserId;
const userInfo = { email: "email", name: "name" }; const userInfo = { email: "email", name: "name", emailVerified: true };
beforeEach(() => { beforeEach(() => {
messagingService = mock(); messagingService = mock();
@@ -86,6 +133,25 @@ describe("accountService", () => {
expect(currentValue).toEqual({ [userId]: userInfo }); expect(currentValue).toEqual({ [userId]: userInfo });
}); });
it("sets the last active date of the account to now", async () => {
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
state.stateSubject.next({});
await sut.addAccount(userId, userInfo);
expect(state.nextMock).toHaveBeenCalledWith({ [userId]: expect.any(Date) });
});
it.each([null, undefined, 123, "not a guid"])(
"does not set last active if the userId is not a valid guid",
async (userId) => {
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
state.stateSubject.next({});
await expect(sut.addAccount(userId as UserId, userInfo)).rejects.toThrow(
"userId is required",
);
},
);
}); });
describe("setAccountName", () => { describe("setAccountName", () => {
@@ -134,6 +200,58 @@ describe("accountService", () => {
}); });
}); });
describe("setAccountEmailVerified", () => {
const initialState = { [userId]: userInfo };
initialState[userId].emailVerified = false;
beforeEach(() => {
accountsState.stateSubject.next(initialState);
});
it("should update the account", async () => {
await sut.setAccountEmailVerified(userId, true);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: { ...userInfo, emailVerified: true },
});
});
it("should not update if the email is the same", async () => {
await sut.setAccountEmailVerified(userId, false);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual(initialState);
});
});
describe("clean", () => {
beforeEach(() => {
accountsState.stateSubject.next({ [userId]: userInfo });
});
it("removes account info of the given user", async () => {
await sut.clean(userId);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: {
email: "",
emailVerified: false,
name: undefined,
},
});
});
it("removes account activity of the given user", async () => {
const state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
state.stateSubject.next({ [userId]: new Date() });
await sut.clean(userId);
expect(state.nextMock).toHaveBeenCalledWith({});
});
});
describe("switchAccount", () => { describe("switchAccount", () => {
beforeEach(() => { beforeEach(() => {
accountsState.stateSubject.next({ [userId]: userInfo }); accountsState.stateSubject.next({ [userId]: userInfo });
@@ -152,4 +270,83 @@ describe("accountService", () => {
expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist"); expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist");
}); });
}); });
describe("account activity", () => {
let state: FakeGlobalState<Record<UserId, Date>>;
beforeEach(() => {
state = globalStateProvider.getFake(ACCOUNT_ACTIVITY);
}); });
describe("accountActivity$", () => {
it("returns the account activity state", async () => {
state.stateSubject.next({
[toId("user1")]: new Date(1),
[toId("user2")]: new Date(2),
});
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({
[toId("user1")]: new Date(1),
[toId("user2")]: new Date(2),
});
});
it("returns an empty object when account activity is null", async () => {
state.stateSubject.next(null);
await expect(firstValueFrom(sut.accountActivity$)).resolves.toEqual({});
});
});
describe("sortedUserIds$", () => {
it("returns the sorted user ids by date with most recent first", async () => {
state.stateSubject.next({
[toId("user1")]: new Date(3),
[toId("user2")]: new Date(2),
[toId("user3")]: new Date(1),
});
await expect(firstValueFrom(sut.sortedUserIds$)).resolves.toEqual([
"user1" as UserId,
"user2" as UserId,
"user3" as UserId,
]);
});
it("returns an empty array when account activity is null", async () => {
state.stateSubject.next(null);
await expect(firstValueFrom(sut.sortedUserIds$)).resolves.toEqual([]);
});
});
describe("setAccountActivity", () => {
const userId = Utils.newGuid() as UserId;
it("sets the account activity", async () => {
await sut.setAccountActivity(userId, new Date(1));
expect(state.nextMock).toHaveBeenCalledWith({ [userId]: new Date(1) });
});
it("does not update if the activity is the same", async () => {
state.stateSubject.next({ [userId]: new Date(1) });
await sut.setAccountActivity(userId, new Date(1));
expect(state.nextMock).not.toHaveBeenCalled();
});
it.each([null, undefined, 123, "not a guid"])(
"does not set last active if the userId is not a valid guid",
async (userId) => {
await sut.setAccountActivity(userId as UserId, new Date(1));
expect(state.nextMock).not.toHaveBeenCalled();
},
);
});
});
});
function toId(userId: string) {
return userId as UserId;
}

View File

@@ -1,4 +1,4 @@
import { Subject, combineLatestWith, map, distinctUntilChanged, shareReplay } from "rxjs"; import { combineLatestWith, map, distinctUntilChanged, shareReplay, combineLatest } from "rxjs";
import { import {
AccountInfo, AccountInfo,
@@ -7,8 +7,9 @@ import {
} from "../../auth/abstractions/account.service"; } from "../../auth/abstractions/account.service";
import { LogService } from "../../platform/abstractions/log.service"; import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service"; import { MessagingService } from "../../platform/abstractions/messaging.service";
import { Utils } from "../../platform/misc/utils";
import { import {
ACCOUNT_MEMORY, ACCOUNT_DISK,
GlobalState, GlobalState,
GlobalStateProvider, GlobalStateProvider,
KeyDefinition, KeyDefinition,
@@ -16,25 +17,36 @@ import {
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>( export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>(
ACCOUNT_MEMORY, ACCOUNT_DISK,
"accounts", "accounts",
{ {
deserializer: (accountInfo) => accountInfo, deserializer: (accountInfo) => accountInfo,
}, },
); );
export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_MEMORY, "activeAccountId", { export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_DISK, "activeAccountId", {
deserializer: (id: UserId) => id, deserializer: (id: UserId) => id,
}); });
export const ACCOUNT_ACTIVITY = KeyDefinition.record<Date, UserId>(ACCOUNT_DISK, "activity", {
deserializer: (activity) => new Date(activity),
});
const LOGGED_OUT_INFO: AccountInfo = {
email: "",
emailVerified: false,
name: undefined,
};
export class AccountServiceImplementation implements InternalAccountService { export class AccountServiceImplementation implements InternalAccountService {
private lock = new Subject<UserId>();
private logout = new Subject<UserId>();
private accountsState: GlobalState<Record<UserId, AccountInfo>>; private accountsState: GlobalState<Record<UserId, AccountInfo>>;
private activeAccountIdState: GlobalState<UserId | undefined>; private activeAccountIdState: GlobalState<UserId | undefined>;
accounts$; accounts$;
activeAccount$; activeAccount$;
accountActivity$;
sortedUserIds$;
nextUpAccount$;
constructor( constructor(
private messagingService: MessagingService, private messagingService: MessagingService,
@@ -53,14 +65,40 @@ export class AccountServiceImplementation implements InternalAccountService {
distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)), distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)),
shareReplay({ bufferSize: 1, refCount: false }), shareReplay({ bufferSize: 1, refCount: false }),
); );
this.accountActivity$ = this.globalStateProvider
.get(ACCOUNT_ACTIVITY)
.state$.pipe(map((activity) => activity ?? {}));
this.sortedUserIds$ = this.accountActivity$.pipe(
map((activity) => {
return Object.entries(activity)
.map(([userId, lastActive]: [UserId, Date]) => ({ userId, lastActive }))
.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime()) // later dates first
.map((a) => a.userId);
}),
);
this.nextUpAccount$ = combineLatest([
this.accounts$,
this.activeAccount$,
this.sortedUserIds$,
]).pipe(
map(([accounts, activeAccount, sortedUserIds]) => {
const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null);
return nextId ? { id: nextId, ...accounts[nextId] } : null;
}),
);
} }
async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> { async addAccount(userId: UserId, accountData: AccountInfo): Promise<void> {
if (!Utils.isGuid(userId)) {
throw new Error("userId is required");
}
await this.accountsState.update((accounts) => { await this.accountsState.update((accounts) => {
accounts ||= {}; accounts ||= {};
accounts[userId] = accountData; accounts[userId] = accountData;
return accounts; return accounts;
}); });
await this.setAccountActivity(userId, new Date());
} }
async setAccountName(userId: UserId, name: string): Promise<void> { async setAccountName(userId: UserId, name: string): Promise<void> {
@@ -71,6 +109,15 @@ export class AccountServiceImplementation implements InternalAccountService {
await this.setAccountInfo(userId, { email }); await this.setAccountInfo(userId, { email });
} }
async setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void> {
await this.setAccountInfo(userId, { emailVerified });
}
async clean(userId: UserId) {
await this.setAccountInfo(userId, LOGGED_OUT_INFO);
await this.removeAccountActivity(userId);
}
async switchAccount(userId: UserId): Promise<void> { async switchAccount(userId: UserId): Promise<void> {
await this.activeAccountIdState.update( await this.activeAccountIdState.update(
(_, accounts) => { (_, accounts) => {
@@ -94,6 +141,37 @@ export class AccountServiceImplementation implements InternalAccountService {
); );
} }
async setAccountActivity(userId: UserId, lastActivity: Date): Promise<void> {
if (!Utils.isGuid(userId)) {
// only store for valid userIds
return;
}
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
(activity) => {
activity ||= {};
activity[userId] = lastActivity;
return activity;
},
{
shouldUpdate: (oldActivity) => oldActivity?.[userId]?.getTime() !== lastActivity?.getTime(),
},
);
}
async removeAccountActivity(userId: UserId): Promise<void> {
await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update(
(activity) => {
if (activity == null) {
return activity;
}
delete activity[userId];
return activity;
},
{ shouldUpdate: (oldActivity) => oldActivity?.[userId] != null },
);
}
// TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow // TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow
async delete(): Promise<void> { async delete(): Promise<void> {
try { try {

View File

@@ -56,6 +56,7 @@ describe("AuthService", () => {
status: AuthenticationStatus.Unlocked, status: AuthenticationStatus.Unlocked,
id: userId, id: userId,
email: "email", email: "email",
emailVerified: false,
name: "name", name: "name",
}; };
@@ -109,6 +110,7 @@ describe("AuthService", () => {
status: AuthenticationStatus.Unlocked, status: AuthenticationStatus.Unlocked,
id: Utils.newGuid() as UserId, id: Utils.newGuid() as UserId,
email: "email2", email: "email2",
emailVerified: false,
name: "name2", name: "name2",
}; };
@@ -126,7 +128,11 @@ describe("AuthService", () => {
it("requests auth status for all known users", async () => { it("requests auth status for all known users", async () => {
const userId2 = Utils.newGuid() as UserId; const userId2 = Utils.newGuid() as UserId;
await accountService.addAccount(userId2, { email: "email2", name: "name2" }); await accountService.addAccount(userId2, {
email: "email2",
emailVerified: false,
name: "name2",
});
const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked)); const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked));
sut.authStatusFor$ = mockFn; sut.authStatusFor$ = mockFn;
@@ -147,11 +153,14 @@ describe("AuthService", () => {
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined)); cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
}); });
it("emits LoggedOut when userId is null", async () => { it.each([null, undefined, "not a userId"])(
"emits LoggedOut when userId is invalid (%s)",
async () => {
expect(await firstValueFrom(sut.authStatusFor$(null))).toEqual( expect(await firstValueFrom(sut.authStatusFor$(null))).toEqual(
AuthenticationStatus.LoggedOut, AuthenticationStatus.LoggedOut,
); );
}); },
);
it("emits LoggedOut when there is no access token", async () => { it("emits LoggedOut when there is no access token", async () => {
tokenService.hasAccessToken$.mockReturnValue(of(false)); tokenService.hasAccessToken$.mockReturnValue(of(false));

View File

@@ -2,6 +2,7 @@ import {
Observable, Observable,
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
firstValueFrom,
map, map,
of, of,
shareReplay, shareReplay,
@@ -12,6 +13,7 @@ import { ApiService } from "../../abstractions/api.service";
import { CryptoService } from "../../platform/abstractions/crypto.service"; import { CryptoService } from "../../platform/abstractions/crypto.service";
import { MessagingService } from "../../platform/abstractions/messaging.service"; import { MessagingService } from "../../platform/abstractions/messaging.service";
import { StateService } from "../../platform/abstractions/state.service"; import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { AccountService } from "../abstractions/account.service"; import { AccountService } from "../abstractions/account.service";
import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service"; import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service";
@@ -39,13 +41,16 @@ export class AuthService implements AuthServiceAbstraction {
this.authStatuses$ = this.accountService.accounts$.pipe( this.authStatuses$ = this.accountService.accounts$.pipe(
map((accounts) => Object.keys(accounts) as UserId[]), map((accounts) => Object.keys(accounts) as UserId[]),
switchMap((entries) => switchMap((entries) => {
combineLatest( if (entries.length === 0) {
return of([] as { userId: UserId; status: AuthenticationStatus }[]);
}
return combineLatest(
entries.map((userId) => entries.map((userId) =>
this.authStatusFor$(userId).pipe(map((status) => ({ userId, status }))), this.authStatusFor$(userId).pipe(map((status) => ({ userId, status }))),
), ),
), );
), }),
map((statuses) => { map((statuses) => {
return statuses.reduce( return statuses.reduce(
(acc, { userId, status }) => { (acc, { userId, status }) => {
@@ -59,7 +64,7 @@ export class AuthService implements AuthServiceAbstraction {
} }
authStatusFor$(userId: UserId): Observable<AuthenticationStatus> { authStatusFor$(userId: UserId): Observable<AuthenticationStatus> {
if (userId == null) { if (!Utils.isGuid(userId)) {
return of(AuthenticationStatus.LoggedOut); return of(AuthenticationStatus.LoggedOut);
} }
@@ -84,17 +89,8 @@ export class AuthService implements AuthServiceAbstraction {
} }
async getAuthStatus(userId?: string): Promise<AuthenticationStatus> { async getAuthStatus(userId?: string): Promise<AuthenticationStatus> {
// If we don't have an access token or userId, we're logged out userId ??= await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
const isAuthenticated = await this.stateService.getIsAuthenticated({ userId: userId }); return await firstValueFrom(this.authStatusFor$(userId as UserId));
if (!isAuthenticated) {
return AuthenticationStatus.LoggedOut;
}
// Note: since we aggresively set the auto user key to memory if it exists on app init (see InitService)
// we only need to check if the user key is in memory.
const hasUserKey = await this.cryptoService.hasUserKeyInMemory(userId as UserId);
return hasUserKey ? AuthenticationStatus.Unlocked : AuthenticationStatus.Locked;
} }
logOut(callback: () => void) { logOut(callback: () => void) {

View File

@@ -90,6 +90,7 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
const user1AccountInfo: AccountInfo = { const user1AccountInfo: AccountInfo = {
name: "Test User 1", name: "Test User 1",
email: "test1@email.com", email: "test1@email.com",
emailVerified: true,
}; };
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId })); activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId }));

View File

@@ -25,11 +25,10 @@ export type InitOptions = {
export abstract class StateService<T extends Account = Account> { export abstract class StateService<T extends Account = Account> {
accounts$: Observable<{ [userId: string]: T }>; accounts$: Observable<{ [userId: string]: T }>;
activeAccount$: Observable<string>;
addAccount: (account: T) => Promise<void>; addAccount: (account: T) => Promise<void>;
setActiveUser: (userId: string) => Promise<void>; clearDecryptedData: (userId: UserId) => Promise<void>;
clean: (options?: StorageOptions) => Promise<UserId>; clean: (options?: StorageOptions) => Promise<void>;
init: (initOptions?: InitOptions) => Promise<void>; init: (initOptions?: InitOptions) => Promise<void>;
/** /**
@@ -122,8 +121,6 @@ export abstract class StateService<T extends Account = Account> {
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>; setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
getEmail: (options?: StorageOptions) => Promise<string>; getEmail: (options?: StorageOptions) => Promise<string>;
setEmail: (value: string, options?: StorageOptions) => Promise<void>; setEmail: (value: string, options?: StorageOptions) => Promise<void>;
getEmailVerified: (options?: StorageOptions) => Promise<boolean>;
setEmailVerified: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>; getEnableBrowserIntegration: (options?: StorageOptions) => Promise<boolean>;
setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>; setEnableBrowserIntegration: (value: boolean, options?: StorageOptions) => Promise<void>;
getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>; getEnableBrowserIntegrationFingerprint: (options?: StorageOptions) => Promise<boolean>;
@@ -147,8 +144,6 @@ export abstract class StateService<T extends Account = Account> {
*/ */
setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise<void>; setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise<void>;
getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>; getIsAuthenticated: (options?: StorageOptions) => Promise<boolean>;
getLastActive: (options?: StorageOptions) => Promise<number>;
setLastActive: (value: number, options?: StorageOptions) => Promise<void>;
getLastSync: (options?: StorageOptions) => Promise<string>; getLastSync: (options?: StorageOptions) => Promise<string>;
setLastSync: (value: string, options?: StorageOptions) => Promise<void>; setLastSync: (value: string, options?: StorageOptions) => Promise<void>;
getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>; getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise<boolean>;
@@ -180,5 +175,4 @@ export abstract class StateService<T extends Account = Account> {
setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>; setVaultTimeout: (value: number, options?: StorageOptions) => Promise<void>;
getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>; getVaultTimeoutAction: (options?: StorageOptions) => Promise<string>;
setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>; setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise<void>;
nextUpActiveUser: () => Promise<UserId>;
} }

View File

@@ -3,6 +3,33 @@ import * as path from "path";
import { Utils } from "./utils"; import { Utils } from "./utils";
describe("Utils Service", () => { describe("Utils Service", () => {
describe("isGuid", () => {
it("is false when null", () => {
expect(Utils.isGuid(null)).toBe(false);
});
it("is false when undefined", () => {
expect(Utils.isGuid(undefined)).toBe(false);
});
it("is false when empty", () => {
expect(Utils.isGuid("")).toBe(false);
});
it("is false when not a string", () => {
expect(Utils.isGuid(123 as any)).toBe(false);
});
it("is false when not a guid", () => {
expect(Utils.isGuid("not a guid")).toBe(false);
});
it("is true when a guid", () => {
// we use a limited guid scope in which all zeroes is invalid
expect(Utils.isGuid("00000000-0000-1000-8000-000000000000")).toBe(true);
});
});
describe("getDomain", () => { describe("getDomain", () => {
it("should fail for invalid urls", () => { it("should fail for invalid urls", () => {
expect(Utils.getDomain(null)).toBeNull(); expect(Utils.getDomain(null)).toBeNull();

View File

@@ -9,9 +9,6 @@ export class State<
> { > {
accounts: { [userId: string]: TAccount } = {}; accounts: { [userId: string]: TAccount } = {};
globals: TGlobalState; globals: TGlobalState;
activeUserId: string;
authenticatedAccounts: string[] = [];
accountActivity: { [userId: string]: number } = {};
constructor(globals: TGlobalState) { constructor(globals: TGlobalState) {
this.globals = globals; this.globals = globals;

View File

@@ -31,10 +31,12 @@ describe("EnvironmentService", () => {
[testUser]: { [testUser]: {
name: "name", name: "name",
email: "email", email: "email",
emailVerified: false,
}, },
[alternateTestUser]: { [alternateTestUser]: {
name: "name", name: "name",
email: "email", email: "email",
emailVerified: false,
}, },
}); });
stateProvider = new FakeStateProvider(accountService); stateProvider = new FakeStateProvider(accountService);
@@ -47,6 +49,7 @@ describe("EnvironmentService", () => {
id: userId, id: userId,
email: "test@example.com", email: "test@example.com",
name: `Test Name ${userId}`, name: `Test Name ${userId}`,
emailVerified: false,
}); });
await awaitAsync(); await awaitAsync();
}; };

View File

@@ -1,4 +1,4 @@
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, firstValueFrom, map } from "rxjs";
import { Jsonify, JsonValue } from "type-fest"; import { Jsonify, JsonValue } from "type-fest";
import { AccountService } from "../../auth/abstractions/account.service"; import { AccountService } from "../../auth/abstractions/account.service";
@@ -33,10 +33,7 @@ const keys = {
state: "state", state: "state",
stateVersion: "stateVersion", stateVersion: "stateVersion",
global: "global", global: "global",
authenticatedAccounts: "authenticatedAccounts",
activeUserId: "activeUserId",
tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication
accountActivity: "accountActivity",
}; };
const partialKeys = { const partialKeys = {
@@ -58,9 +55,6 @@ export class StateService<
protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({}); protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({});
accounts$ = this.accountsSubject.asObservable(); accounts$ = this.accountsSubject.asObservable();
protected activeAccountSubject = new BehaviorSubject<string | null>(null);
activeAccount$ = this.activeAccountSubject.asObservable();
private hasBeenInited = false; private hasBeenInited = false;
protected isRecoveredSession = false; protected isRecoveredSession = false;
@@ -112,36 +106,16 @@ export class StateService<
} }
// Get all likely authenticated accounts // Get all likely authenticated accounts
const authenticatedAccounts = ( const authenticatedAccounts = await firstValueFrom(
(await this.storageService.get<string[]>(keys.authenticatedAccounts)) ?? [] this.accountService.accounts$.pipe(map((accounts) => Object.keys(accounts))),
).filter((account) => account != null); );
await this.updateState(async (state) => { await this.updateState(async (state) => {
for (const i in authenticatedAccounts) { for (const i in authenticatedAccounts) {
state = await this.syncAccountFromDisk(authenticatedAccounts[i]); state = await this.syncAccountFromDisk(authenticatedAccounts[i]);
} }
// After all individual accounts have been added
state.authenticatedAccounts = authenticatedAccounts;
const storedActiveUser = await this.storageService.get<string>(keys.activeUserId);
if (storedActiveUser != null) {
state.activeUserId = storedActiveUser;
}
await this.pushAccounts(); await this.pushAccounts();
this.activeAccountSubject.next(state.activeUserId);
// TODO: Temporary update to avoid routing all account status changes through account service for now.
// account service tracks logged out accounts, but State service does not, so we need to add the active account
// if it's not in the accounts list.
if (state.activeUserId != null && this.accountsSubject.value[state.activeUserId] == null) {
const activeDiskAccount = await this.getAccountFromDisk({ userId: state.activeUserId });
await this.accountService.addAccount(state.activeUserId as UserId, {
name: activeDiskAccount.profile.name,
email: activeDiskAccount.profile.email,
});
}
await this.accountService.switchAccount(state.activeUserId as UserId);
// End TODO
return state; return state;
}); });
@@ -161,61 +135,25 @@ export class StateService<
return state; return state;
}); });
// TODO: Temporary update to avoid routing all account status changes through account service for now.
// The determination of state should be handled by the various services that control those values.
await this.accountService.addAccount(userId as UserId, {
name: diskAccount.profile.name,
email: diskAccount.profile.email,
});
return state; return state;
} }
async addAccount(account: TAccount) { async addAccount(account: TAccount) {
await this.environmentService.seedUserEnvironment(account.profile.userId as UserId); await this.environmentService.seedUserEnvironment(account.profile.userId as UserId);
await this.updateState(async (state) => { await this.updateState(async (state) => {
state.authenticatedAccounts.push(account.profile.userId);
await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts);
state.accounts[account.profile.userId] = account; state.accounts[account.profile.userId] = account;
return state; return state;
}); });
await this.scaffoldNewAccountStorage(account); await this.scaffoldNewAccountStorage(account);
await this.setLastActive(new Date().getTime(), { userId: account.profile.userId });
// TODO: Temporary update to avoid routing all account status changes through account service for now.
await this.accountService.addAccount(account.profile.userId as UserId, {
name: account.profile.name,
email: account.profile.email,
});
await this.setActiveUser(account.profile.userId);
} }
async setActiveUser(userId: string): Promise<void> { async clean(options?: StorageOptions): Promise<void> {
await this.clearDecryptedDataForActiveUser();
await this.updateState(async (state) => {
state.activeUserId = userId;
await this.storageService.save(keys.activeUserId, userId);
this.activeAccountSubject.next(state.activeUserId);
// TODO: temporary update to avoid routing all account status changes through account service for now.
await this.accountService.switchAccount(userId as UserId);
return state;
});
await this.pushAccounts();
}
async clean(options?: StorageOptions): Promise<UserId> {
options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
await this.deAuthenticateAccount(options.userId); await this.deAuthenticateAccount(options.userId);
let currentUser = (await this.state())?.activeUserId;
if (options.userId === currentUser) {
currentUser = await this.dynamicallySetActiveUser();
}
await this.removeAccountFromDisk(options?.userId); await this.removeAccountFromDisk(options?.userId);
await this.removeAccountFromMemory(options?.userId); await this.removeAccountFromMemory(options?.userId);
await this.pushAccounts(); await this.pushAccounts();
return currentUser as UserId;
} }
/** /**
@@ -515,24 +453,6 @@ export class StateService<
); );
} }
async getEmailVerified(options?: StorageOptions): Promise<boolean> {
return (
(await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
?.profile.emailVerified ?? false
);
}
async setEmailVerified(value: boolean, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
account.profile.emailVerified = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> { async getEnableBrowserIntegration(options?: StorageOptions): Promise<boolean> {
return ( return (
(await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())))
@@ -642,35 +562,6 @@ export class StateService<
); );
} }
async getLastActive(options?: StorageOptions): Promise<number> {
options = this.reconcileOptions(options, await this.defaultOnDiskOptions());
const accountActivity = await this.storageService.get<{ [userId: string]: number }>(
keys.accountActivity,
options,
);
if (accountActivity == null || Object.keys(accountActivity).length < 1) {
return null;
}
return accountActivity[options.userId];
}
async setLastActive(value: number, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(options, await this.defaultOnDiskOptions());
if (options.userId == null) {
return;
}
const accountActivity =
(await this.storageService.get<{ [userId: string]: number }>(
keys.accountActivity,
options,
)) ?? {};
accountActivity[options.userId] = value;
await this.storageService.save(keys.accountActivity, accountActivity, options);
}
async getLastSync(options?: StorageOptions): Promise<string> { async getLastSync(options?: StorageOptions): Promise<string> {
return ( return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions())) await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()))
@@ -910,24 +801,28 @@ export class StateService<
} }
protected async getAccountFromMemory(options: StorageOptions): Promise<TAccount> { protected async getAccountFromMemory(options: StorageOptions): Promise<TAccount> {
const userId =
options.userId ??
(await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
));
return await this.state().then(async (state) => { return await this.state().then(async (state) => {
if (state.accounts == null) { if (state.accounts == null) {
return null; return null;
} }
return state.accounts[await this.getUserIdFromMemory(options)]; return state.accounts[userId];
});
}
protected async getUserIdFromMemory(options: StorageOptions): Promise<string> {
return await this.state().then((state) => {
return options?.userId != null
? state.accounts[options.userId]?.profile?.userId
: state.activeUserId;
}); });
} }
protected async getAccountFromDisk(options: StorageOptions): Promise<TAccount> { protected async getAccountFromDisk(options: StorageOptions): Promise<TAccount> {
if (options?.userId == null && (await this.state())?.activeUserId == null) { const userId =
options.userId ??
(await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
));
if (userId == null) {
return null; return null;
} }
@@ -1086,53 +981,76 @@ export class StateService<
} }
protected async defaultInMemoryOptions(): Promise<StorageOptions> { protected async defaultInMemoryOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return { return {
storageLocation: StorageLocation.Memory, storageLocation: StorageLocation.Memory,
userId: (await this.state()).activeUserId, userId,
}; };
} }
protected async defaultOnDiskOptions(): Promise<StorageOptions> { protected async defaultOnDiskOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return { return {
storageLocation: StorageLocation.Disk, storageLocation: StorageLocation.Disk,
htmlStorageLocation: HtmlStorageLocation.Session, htmlStorageLocation: HtmlStorageLocation.Session,
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()), userId,
useSecureStorage: false, useSecureStorage: false,
}; };
} }
protected async defaultOnDiskLocalOptions(): Promise<StorageOptions> { protected async defaultOnDiskLocalOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return { return {
storageLocation: StorageLocation.Disk, storageLocation: StorageLocation.Disk,
htmlStorageLocation: HtmlStorageLocation.Local, htmlStorageLocation: HtmlStorageLocation.Local,
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()), userId,
useSecureStorage: false, useSecureStorage: false,
}; };
} }
protected async defaultOnDiskMemoryOptions(): Promise<StorageOptions> { protected async defaultOnDiskMemoryOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return { return {
storageLocation: StorageLocation.Disk, storageLocation: StorageLocation.Disk,
htmlStorageLocation: HtmlStorageLocation.Memory, htmlStorageLocation: HtmlStorageLocation.Memory,
userId: (await this.state())?.activeUserId ?? (await this.getUserId()), userId,
useSecureStorage: false, useSecureStorage: false,
}; };
} }
protected async defaultSecureStorageOptions(): Promise<StorageOptions> { protected async defaultSecureStorageOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return { return {
storageLocation: StorageLocation.Disk, storageLocation: StorageLocation.Disk,
useSecureStorage: true, useSecureStorage: true,
userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()), userId,
}; };
} }
protected async getActiveUserIdFromStorage(): Promise<string> { protected async getActiveUserIdFromStorage(): Promise<string> {
return await this.storageService.get<string>(keys.activeUserId); return await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
} }
protected async removeAccountFromLocalStorage(userId: string = null): Promise<void> { protected async removeAccountFromLocalStorage(userId: string = null): Promise<void> {
userId = userId ?? (await this.state())?.activeUserId; userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
const storedAccount = await this.getAccount( const storedAccount = await this.getAccount(
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()), this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()),
); );
@@ -1143,7 +1061,10 @@ export class StateService<
} }
protected async removeAccountFromSessionStorage(userId: string = null): Promise<void> { protected async removeAccountFromSessionStorage(userId: string = null): Promise<void> {
userId = userId ?? (await this.state())?.activeUserId; userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
const storedAccount = await this.getAccount( const storedAccount = await this.getAccount(
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()), this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()),
); );
@@ -1154,7 +1075,10 @@ export class StateService<
} }
protected async removeAccountFromSecureStorage(userId: string = null): Promise<void> { protected async removeAccountFromSecureStorage(userId: string = null): Promise<void> {
userId = userId ?? (await this.state())?.activeUserId; userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
await this.setUserKeyAutoUnlock(null, { userId: userId }); await this.setUserKeyAutoUnlock(null, { userId: userId });
await this.setUserKeyBiometric(null, { userId: userId }); await this.setUserKeyBiometric(null, { userId: userId });
await this.setCryptoMasterKeyAuto(null, { userId: userId }); await this.setCryptoMasterKeyAuto(null, { userId: userId });
@@ -1163,8 +1087,11 @@ export class StateService<
} }
protected async removeAccountFromMemory(userId: string = null): Promise<void> { protected async removeAccountFromMemory(userId: string = null): Promise<void> {
userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
await this.updateState(async (state) => { await this.updateState(async (state) => {
userId = userId ?? state.activeUserId;
delete state.accounts[userId]; delete state.accounts[userId];
return state; return state;
}); });
@@ -1178,15 +1105,16 @@ export class StateService<
return Object.assign(this.createAccount(), persistentAccountInformation); return Object.assign(this.createAccount(), persistentAccountInformation);
} }
protected async clearDecryptedDataForActiveUser(): Promise<void> { async clearDecryptedData(userId: UserId): Promise<void> {
await this.updateState(async (state) => { await this.updateState(async (state) => {
const userId = state?.activeUserId;
if (userId != null && state?.accounts[userId]?.data != null) { if (userId != null && state?.accounts[userId]?.data != null) {
state.accounts[userId].data = new AccountData(); state.accounts[userId].data = new AccountData();
} }
return state; return state;
}); });
await this.pushAccounts();
} }
protected createAccount(init: Partial<TAccount> = null): TAccount { protected createAccount(init: Partial<TAccount> = null): TAccount {
@@ -1201,14 +1129,6 @@ export class StateService<
// We must have a manual call to clear tokens as we can't leverage state provider to clean // We must have a manual call to clear tokens as we can't leverage state provider to clean
// up our data as we have secure storage in the mix. // up our data as we have secure storage in the mix.
await this.tokenService.clearTokens(userId as UserId); await this.tokenService.clearTokens(userId as UserId);
await this.setLastActive(null, { userId: userId });
await this.updateState(async (state) => {
state.authenticatedAccounts = state.authenticatedAccounts.filter((id) => id !== userId);
await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts);
return state;
});
} }
protected async removeAccountFromDisk(userId: string) { protected async removeAccountFromDisk(userId: string) {
@@ -1217,32 +1137,6 @@ export class StateService<
await this.removeAccountFromSecureStorage(userId); await this.removeAccountFromSecureStorage(userId);
} }
async nextUpActiveUser() {
const accounts = (await this.state())?.accounts;
if (accounts == null || Object.keys(accounts).length < 1) {
return null;
}
let newActiveUser;
for (const userId in accounts) {
if (userId == null) {
continue;
}
if (await this.getIsAuthenticated({ userId: userId })) {
newActiveUser = userId;
break;
}
newActiveUser = null;
}
return newActiveUser as UserId;
}
protected async dynamicallySetActiveUser() {
const newActiveUser = await this.nextUpActiveUser();
await this.setActiveUser(newActiveUser);
return newActiveUser;
}
protected async saveSecureStorageKey<T extends JsonValue>( protected async saveSecureStorageKey<T extends JsonValue>(
key: string, key: string,
value: T, value: T,

View File

@@ -1,10 +1,12 @@
import { firstValueFrom, timeout } from "rxjs"; import { firstValueFrom, map, timeout } from "rxjs";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountService } from "../../auth/abstractions/account.service";
import { AuthService } from "../../auth/abstractions/auth.service"; import { AuthService } from "../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service"; import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service";
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
import { UserId } from "../../types/guid";
import { MessagingService } from "../abstractions/messaging.service"; import { MessagingService } from "../abstractions/messaging.service";
import { PlatformUtilsService } from "../abstractions/platform-utils.service"; import { PlatformUtilsService } from "../abstractions/platform-utils.service";
import { StateService } from "../abstractions/state.service"; import { StateService } from "../abstractions/state.service";
@@ -25,15 +27,18 @@ export class SystemService implements SystemServiceAbstraction {
private autofillSettingsService: AutofillSettingsServiceAbstraction, private autofillSettingsService: AutofillSettingsServiceAbstraction,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private biometricStateService: BiometricStateService, private biometricStateService: BiometricStateService,
private accountService: AccountService,
) {} ) {}
async startProcessReload(authService: AuthService): Promise<void> { async startProcessReload(authService: AuthService): Promise<void> {
const accounts = await firstValueFrom(this.stateService.accounts$); const accounts = await firstValueFrom(this.accountService.accounts$);
if (accounts != null) { if (accounts != null) {
const keys = Object.keys(accounts); const keys = Object.keys(accounts);
if (keys.length > 0) { if (keys.length > 0) {
for (const userId of keys) { for (const userId of keys) {
if ((await authService.getAuthStatus(userId)) === AuthenticationStatus.Unlocked) { let status = await firstValueFrom(authService.authStatusFor$(userId as UserId));
status = await authService.getAuthStatus(userId);
if (status === AuthenticationStatus.Unlocked) {
return; return;
} }
} }
@@ -63,15 +68,24 @@ export class SystemService implements SystemServiceAbstraction {
clearInterval(this.reloadInterval); clearInterval(this.reloadInterval);
this.reloadInterval = null; this.reloadInterval = null;
const currentUser = await firstValueFrom(this.stateService.activeAccount$.pipe(timeout(500))); const currentUser = await firstValueFrom(
this.accountService.activeAccount$.pipe(
map((a) => a?.id),
timeout(500),
),
);
// Replace current active user if they will be logged out on reload // Replace current active user if they will be logged out on reload
if (currentUser != null) { if (currentUser != null) {
const timeoutAction = await firstValueFrom( const timeoutAction = await firstValueFrom(
this.vaultTimeoutSettingsService.vaultTimeoutAction$().pipe(timeout(500)), this.vaultTimeoutSettingsService.vaultTimeoutAction$().pipe(timeout(500)),
); );
if (timeoutAction === VaultTimeoutAction.LogOut) { if (timeoutAction === VaultTimeoutAction.LogOut) {
const nextUser = await this.stateService.nextUpActiveUser(); const nextUser = await firstValueFrom(
await this.stateService.setActiveUser(nextUser); this.accountService.nextUpAccount$.pipe(map((account) => account?.id ?? null)),
);
// Can be removed once we migrate password generation history to state providers
await this.stateService.clearDecryptedData(currentUser);
await this.accountService.switchAccount(nextUser);
} }
} }

View File

@@ -1,7 +1,6 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { mockAccountServiceWith, trackEmissions } from "../../../../spec"; import { mockAccountServiceWith, trackEmissions } from "../../../../spec";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { SingleUserStateProvider } from "../user-state.provider"; import { SingleUserStateProvider } from "../user-state.provider";
@@ -14,7 +13,7 @@ describe("DefaultActiveUserStateProvider", () => {
id: userId, id: userId,
name: "name", name: "name",
email: "email", email: "email",
status: AuthenticationStatus.Locked, emailVerified: false,
}; };
const accountService = mockAccountServiceWith(userId, accountInfo); const accountService = mockAccountServiceWith(userId, accountInfo);
let sut: DefaultActiveUserStateProvider; let sut: DefaultActiveUserStateProvider;

View File

@@ -82,6 +82,7 @@ describe("DefaultActiveUserState", () => {
activeAccountSubject.next({ activeAccountSubject.next({
id: userId, id: userId,
email: `test${id}@example.com`, email: `test${id}@example.com`,
emailVerified: false,
name: `Test User ${id}`, name: `Test User ${id}`,
}); });
await awaitAsync(); await awaitAsync();

View File

@@ -69,7 +69,12 @@ describe("DefaultStateProvider", () => {
userId?: UserId, userId?: UserId,
) => Observable<string>, ) => Observable<string>,
) => { ) => {
const accountInfo = { email: "email", name: "name", status: AuthenticationStatus.LoggedOut }; const accountInfo = {
email: "email",
emailVerified: false,
name: "name",
status: AuthenticationStatus.LoggedOut,
};
const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", { const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", {
deserializer: (s) => s, deserializer: (s) => s,
}); });
@@ -114,7 +119,12 @@ describe("DefaultStateProvider", () => {
); );
describe("getUserState$", () => { describe("getUserState$", () => {
const accountInfo = { email: "email", name: "name", status: AuthenticationStatus.LoggedOut }; const accountInfo = {
email: "email",
emailVerified: false,
name: "name",
status: AuthenticationStatus.LoggedOut,
};
const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", { const keyDefinition = new KeyDefinition<string>(new StateDefinition("test", "disk"), "test", {
deserializer: (s) => s, deserializer: (s) => s,
}); });

View File

@@ -38,6 +38,7 @@ export const BILLING_DISK = new StateDefinition("billing", "disk");
export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk"); export const KDF_CONFIG_DISK = new StateDefinition("kdfConfig", "disk");
export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk"); export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk");
export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); export const ACCOUNT_MEMORY = new StateDefinition("account", "memory");
export const ACCOUNT_DISK = new StateDefinition("account", "disk");
export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory"); export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory");
export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk"); export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk");
export const TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory"); export const TWO_FACTOR_MEMORY = new StateDefinition("twoFactor", "memory");

View File

@@ -1,9 +1,10 @@
import { MockProxy, any, mock } from "jest-mock-extended"; import { MockProxy, any, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject, of } from "rxjs";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { SearchService } from "../../abstractions/search.service"; import { SearchService } from "../../abstractions/search.service";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountInfo } from "../../auth/abstractions/account.service";
import { AuthService } from "../../auth/abstractions/auth.service"; import { AuthService } from "../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import { FakeMasterPasswordService } from "../../auth/services/master-password/fake-master-password.service"; import { FakeMasterPasswordService } from "../../auth/services/master-password/fake-master-password.service";
@@ -13,7 +14,6 @@ import { MessagingService } from "../../platform/abstractions/messaging.service"
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { StateService } from "../../platform/abstractions/state.service"; import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils"; import { Utils } from "../../platform/misc/utils";
import { Account } from "../../platform/models/domain/account";
import { StateEventRunnerService } from "../../platform/state"; import { StateEventRunnerService } from "../../platform/state";
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { CipherService } from "../../vault/abstractions/cipher.service"; import { CipherService } from "../../vault/abstractions/cipher.service";
@@ -39,7 +39,6 @@ describe("VaultTimeoutService", () => {
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>; let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>; let loggedOutCallback: jest.Mock<Promise<void>, [expired: boolean, userId?: string]>;
let accountsSubject: BehaviorSubject<Record<string, Account>>;
let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>; let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>;
let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>; let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>;
@@ -65,10 +64,6 @@ describe("VaultTimeoutService", () => {
lockedCallback = jest.fn(); lockedCallback = jest.fn();
loggedOutCallback = jest.fn(); loggedOutCallback = jest.fn();
accountsSubject = new BehaviorSubject(null);
stateService.accounts$ = accountsSubject;
vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock); vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock);
vaultTimeoutSettingsService.vaultTimeoutAction$.mockReturnValue(vaultTimeoutActionSubject); vaultTimeoutSettingsService.vaultTimeoutAction$.mockReturnValue(vaultTimeoutActionSubject);
@@ -127,21 +122,39 @@ describe("VaultTimeoutService", () => {
return Promise.resolve(accounts[userId]?.vaultTimeout); return Promise.resolve(accounts[userId]?.vaultTimeout);
}); });
stateService.getLastActive.mockImplementation((options) => {
return Promise.resolve(accounts[options.userId]?.lastActive);
});
stateService.getUserId.mockResolvedValue(globalSetups?.userId); stateService.getUserId.mockResolvedValue(globalSetups?.userId);
stateService.activeAccount$ = new BehaviorSubject<string>(globalSetups?.userId); // Set desired user active and known users on accounts service : note the only thing that matters here is that the ID are set
if (globalSetups?.userId) { if (globalSetups?.userId) {
accountService.activeAccountSubject.next({ accountService.activeAccountSubject.next({
id: globalSetups.userId as UserId, id: globalSetups.userId as UserId,
email: null, email: null,
emailVerified: false,
name: null, name: null,
}); });
} }
accountService.accounts$ = of(
Object.entries(accounts).reduce(
(agg, [id]) => {
agg[id] = {
email: "",
emailVerified: true,
name: "",
};
return agg;
},
{} as Record<string, AccountInfo>,
),
);
accountService.accountActivity$ = of(
Object.entries(accounts).reduce(
(agg, [id, info]) => {
agg[id] = info.lastActive ? new Date(info.lastActive) : null;
return agg;
},
{} as Record<string, Date>,
),
);
platformUtilsService.isViewOpen.mockResolvedValue(globalSetups?.isViewOpen ?? false); platformUtilsService.isViewOpen.mockResolvedValue(globalSetups?.isViewOpen ?? false);
@@ -158,16 +171,6 @@ describe("VaultTimeoutService", () => {
], ],
); );
}); });
const accountsSubjectValue: Record<string, Account> = Object.keys(accounts).reduce(
(agg, key) => {
const newPartial: Record<string, unknown> = {};
newPartial[key] = null; // No values actually matter on this other than the key
return Object.assign(agg, newPartial);
},
{} as Record<string, Account>,
);
accountsSubject.next(accountsSubjectValue);
}; };
const expectUserToHaveLocked = (userId: string) => { const expectUserToHaveLocked = (userId: string) => {

View File

@@ -1,4 +1,4 @@
import { firstValueFrom, timeout } from "rxjs"; import { combineLatest, firstValueFrom, switchMap } from "rxjs";
import { SearchService } from "../../abstractions/search.service"; import { SearchService } from "../../abstractions/search.service";
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
@@ -64,14 +64,25 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
// Get whether or not the view is open a single time so it can be compared for each user // Get whether or not the view is open a single time so it can be compared for each user
const isViewOpen = await this.platformUtilsService.isViewOpen(); const isViewOpen = await this.platformUtilsService.isViewOpen();
const activeUserId = await firstValueFrom(this.stateService.activeAccount$.pipe(timeout(500))); await firstValueFrom(
combineLatest([
const accounts = await firstValueFrom(this.stateService.accounts$); this.accountService.activeAccount$,
for (const userId in accounts) { this.accountService.accountActivity$,
if (userId != null && (await this.shouldLock(userId, activeUserId, isViewOpen))) { ]).pipe(
switchMap(async ([activeAccount, accountActivity]) => {
const activeUserId = activeAccount?.id;
for (const userIdString in accountActivity) {
const userId = userIdString as UserId;
if (
userId != null &&
(await this.shouldLock(userId, accountActivity[userId], activeUserId, isViewOpen))
) {
await this.executeTimeoutAction(userId); await this.executeTimeoutAction(userId);
} }
} }
}),
),
);
} }
async lock(userId?: string): Promise<void> { async lock(userId?: string): Promise<void> {
@@ -123,6 +134,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
private async shouldLock( private async shouldLock(
userId: string, userId: string,
lastActive: Date,
activeUserId: string, activeUserId: string,
isViewOpen: boolean, isViewOpen: boolean,
): Promise<boolean> { ): Promise<boolean> {
@@ -146,13 +158,12 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
return false; return false;
} }
const lastActive = await this.stateService.getLastActive({ userId: userId });
if (lastActive == null) { if (lastActive == null) {
return false; return false;
} }
const vaultTimeoutSeconds = vaultTimeout * 60; const vaultTimeoutSeconds = vaultTimeout * 60;
const diffSeconds = (new Date().getTime() - lastActive) / 1000; const diffSeconds = (new Date().getTime() - lastActive.getTime()) / 1000;
return diffSeconds >= vaultTimeoutSeconds; return diffSeconds >= vaultTimeoutSeconds;
} }

View File

@@ -57,13 +57,14 @@ import { CipherServiceMigrator } from "./migrations/57-move-cipher-service-to-st
import { RemoveRefreshTokenMigratedFlagMigrator } from "./migrations/58-remove-refresh-token-migrated-state-provider-flag"; import { RemoveRefreshTokenMigratedFlagMigrator } from "./migrations/58-remove-refresh-token-migrated-state-provider-flag";
import { KdfConfigMigrator } from "./migrations/59-move-kdf-config-to-state-provider"; import { KdfConfigMigrator } from "./migrations/59-move-kdf-config-to-state-provider";
import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key";
import { KnownAccountsMigrator } from "./migrations/60-known-accounts";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global"; import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global";
import { MinVersionMigrator } from "./migrations/min-version"; import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3; export const MIN_VERSION = 3;
export const CURRENT_VERSION = 59; export const CURRENT_VERSION = 60;
export type MinVersion = typeof MIN_VERSION; export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() { export function createMigrationBuilder() {
@@ -124,7 +125,8 @@ export function createMigrationBuilder() {
.with(AuthRequestMigrator, 55, 56) .with(AuthRequestMigrator, 55, 56)
.with(CipherServiceMigrator, 56, 57) .with(CipherServiceMigrator, 56, 57)
.with(RemoveRefreshTokenMigratedFlagMigrator, 57, 58) .with(RemoveRefreshTokenMigratedFlagMigrator, 57, 58)
.with(KdfConfigMigrator, 58, CURRENT_VERSION); .with(KdfConfigMigrator, 58, 59)
.with(KnownAccountsMigrator, 59, CURRENT_VERSION);
} }
export async function currentVersion( export async function currentVersion(

View File

@@ -27,6 +27,14 @@ const exampleJSON = {
}, },
global_serviceName_key: "global_serviceName_key", global_serviceName_key: "global_serviceName_key",
user_userId_serviceName_key: "user_userId_serviceName_key", user_userId_serviceName_key: "user_userId_serviceName_key",
global_account_accounts: {
"c493ed01-4e08-4e88-abc7-332f380ca760": {
otherStuff: "otherStuff3",
},
"23e61a5f-2ece-4f5e-b499-f0bc489482a9": {
otherStuff: "otherStuff4",
},
},
}; };
describe("RemoveLegacyEtmKeyMigrator", () => { describe("RemoveLegacyEtmKeyMigrator", () => {
@@ -81,6 +89,41 @@ describe("RemoveLegacyEtmKeyMigrator", () => {
const accounts = await sut.getAccounts(); const accounts = await sut.getAccounts();
expect(accounts).toEqual([]); expect(accounts).toEqual([]);
}); });
it("handles global scoped known accounts for version 60 and after", async () => {
sut.currentVersion = 60;
const accounts = await sut.getAccounts();
expect(accounts).toEqual([
// Note, still gets values stored in state service objects, just grabs user ids from global
{
userId: "c493ed01-4e08-4e88-abc7-332f380ca760",
account: { otherStuff: "otherStuff1" },
},
{
userId: "23e61a5f-2ece-4f5e-b499-f0bc489482a9",
account: { otherStuff: "otherStuff2" },
},
]);
});
});
describe("getKnownUserIds", () => {
it("returns all user ids", async () => {
const userIds = await sut.getKnownUserIds();
expect(userIds).toEqual([
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
]);
});
it("returns all user ids when version is 60 or greater", async () => {
sut.currentVersion = 60;
const userIds = await sut.getKnownUserIds();
expect(userIds).toEqual([
"c493ed01-4e08-4e88-abc7-332f380ca760",
"23e61a5f-2ece-4f5e-b499-f0bc489482a9",
]);
});
}); });
describe("getFromGlobal", () => { describe("getFromGlobal", () => {

View File

@@ -162,7 +162,7 @@ export class MigrationHelper {
async getAccounts<ExpectedAccountType>(): Promise< async getAccounts<ExpectedAccountType>(): Promise<
{ userId: string; account: ExpectedAccountType }[] { userId: string; account: ExpectedAccountType }[]
> { > {
const userIds = (await this.get<string[]>("authenticatedAccounts")) ?? []; const userIds = await this.getKnownUserIds();
return Promise.all( return Promise.all(
userIds.map(async (userId) => ({ userIds.map(async (userId) => ({
userId, userId,
@@ -171,6 +171,17 @@ export class MigrationHelper {
); );
} }
/**
* Helper method to read known users ids.
*/
async getKnownUserIds(): Promise<string[]> {
if (this.currentVersion < 61) {
return knownAccountUserIdsBuilderPre61(this.storageService);
} else {
return knownAccountUserIdsBuilder(this.storageService);
}
}
/** /**
* Builds a user storage key appropriate for the current version. * Builds a user storage key appropriate for the current version.
* *
@@ -233,3 +244,18 @@ function globalKeyBuilder(keyDefinition: KeyDefinitionLike): string {
function globalKeyBuilderPre9(): string { function globalKeyBuilderPre9(): string {
throw Error("No key builder should be used for versions prior to 9."); throw Error("No key builder should be used for versions prior to 9.");
} }
async function knownAccountUserIdsBuilderPre61(
storageService: AbstractStorageService,
): Promise<string[]> {
return (await storageService.get<string[]>("authenticatedAccounts")) ?? [];
}
async function knownAccountUserIdsBuilder(
storageService: AbstractStorageService,
): Promise<string[]> {
const accounts = await storageService.get<Record<string, unknown>>(
globalKeyBuilder({ stateDefinition: { name: "account" }, key: "accounts" }),
);
return Object.keys(accounts ?? {});
}

View File

@@ -0,0 +1,145 @@
import { MockProxy } from "jest-mock-extended";
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
ACCOUNT_ACCOUNTS,
ACCOUNT_ACTIVE_ACCOUNT_ID,
ACCOUNT_ACTIVITY,
KnownAccountsMigrator,
} from "./60-known-accounts";
const migrateJson = () => {
return {
authenticatedAccounts: ["user1", "user2"],
activeUserId: "user1",
user1: {
profile: {
email: "user1",
name: "User 1",
emailVerified: true,
},
},
user2: {
profile: {
email: "",
emailVerified: false,
},
},
accountActivity: {
user1: 1609459200000, // 2021-01-01
user2: 1609545600000, // 2021-01-02
},
};
};
const rollbackJson = () => {
return {
user1: {
profile: {
email: "user1",
name: "User 1",
emailVerified: true,
},
},
user2: {
profile: {
email: "",
emailVerified: false,
},
},
global_account_accounts: {
user1: {
profile: {
email: "user1",
name: "User 1",
emailVerified: true,
},
},
user2: {
profile: {
email: "",
emailVerified: false,
},
},
},
global_account_activeAccountId: "user1",
global_account_activity: {
user1: "2021-01-01T00:00:00.000Z",
user2: "2021-01-02T00:00:00.000Z",
},
};
};
describe("ReplicateKnownAccounts", () => {
let helper: MockProxy<MigrationHelper>;
let sut: KnownAccountsMigrator;
describe("migrate", () => {
beforeEach(() => {
helper = mockMigrationHelper(migrateJson(), 59);
sut = new KnownAccountsMigrator(59, 60);
});
it("migrates accounts", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACCOUNTS, {
user1: {
email: "user1",
name: "User 1",
emailVerified: true,
},
user2: {
email: "",
emailVerified: false,
name: undefined,
},
});
expect(helper.remove).toHaveBeenCalledWith("authenticatedAccounts");
});
it("migrates active account it", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVE_ACCOUNT_ID, "user1");
expect(helper.remove).toHaveBeenCalledWith("activeUserId");
});
it("migrates account activity", async () => {
await sut.migrate(helper);
expect(helper.setToGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVITY, {
user1: '"2021-01-01T00:00:00.000Z"',
user2: '"2021-01-02T00:00:00.000Z"',
});
expect(helper.remove).toHaveBeenCalledWith("accountActivity");
});
});
describe("rollback", () => {
beforeEach(() => {
helper = mockMigrationHelper(rollbackJson(), 60);
sut = new KnownAccountsMigrator(59, 60);
});
it("rolls back authenticated accounts", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("authenticatedAccounts", ["user1", "user2"]);
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACCOUNTS);
});
it("rolls back active account id", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("activeUserId", "user1");
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVE_ACCOUNT_ID);
});
it("rolls back account activity", async () => {
await sut.rollback(helper);
expect(helper.set).toHaveBeenCalledWith("accountActivity", {
user1: 1609459200000,
user2: 1609545600000,
});
expect(helper.removeFromGlobal).toHaveBeenCalledWith(ACCOUNT_ACTIVITY);
});
});
});

View File

@@ -0,0 +1,111 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
export const ACCOUNT_ACCOUNTS: KeyDefinitionLike = {
stateDefinition: {
name: "account",
},
key: "accounts",
};
export const ACCOUNT_ACTIVE_ACCOUNT_ID: KeyDefinitionLike = {
stateDefinition: {
name: "account",
},
key: "activeAccountId",
};
export const ACCOUNT_ACTIVITY: KeyDefinitionLike = {
stateDefinition: {
name: "account",
},
key: "activity",
};
type ExpectedAccountType = {
profile?: {
email?: string;
name?: string;
emailVerified?: boolean;
};
};
export class KnownAccountsMigrator extends Migrator<59, 60> {
async migrate(helper: MigrationHelper): Promise<void> {
await this.migrateAuthenticatedAccounts(helper);
await this.migrateActiveAccountId(helper);
await this.migrateAccountActivity(helper);
}
async rollback(helper: MigrationHelper): Promise<void> {
// authenticated account are removed, but the accounts record also contains logged out accounts. Best we can do is to add them all back
const accounts = (await helper.getFromGlobal<Record<string, unknown>>(ACCOUNT_ACCOUNTS)) ?? {};
await helper.set("authenticatedAccounts", Object.keys(accounts));
await helper.removeFromGlobal(ACCOUNT_ACCOUNTS);
// Active Account Id
const activeAccountId = await helper.getFromGlobal<string>(ACCOUNT_ACTIVE_ACCOUNT_ID);
if (activeAccountId) {
await helper.set("activeUserId", activeAccountId);
}
await helper.removeFromGlobal(ACCOUNT_ACTIVE_ACCOUNT_ID);
// Account Activity
const accountActivity = await helper.getFromGlobal<Record<string, string>>(ACCOUNT_ACTIVITY);
if (accountActivity) {
const toStore = Object.entries(accountActivity).reduce(
(agg, [userId, dateString]) => {
agg[userId] = new Date(dateString).getTime();
return agg;
},
{} as Record<string, number>,
);
await helper.set("accountActivity", toStore);
}
await helper.removeFromGlobal(ACCOUNT_ACTIVITY);
}
private async migrateAuthenticatedAccounts(helper: MigrationHelper) {
const authenticatedAccounts = (await helper.get<string[]>("authenticatedAccounts")) ?? [];
const accounts = await Promise.all(
authenticatedAccounts.map(async (userId) => {
const account = await helper.get<ExpectedAccountType>(userId);
return { userId, account };
}),
);
const accountsToStore = accounts.reduce(
(agg, { userId, account }) => {
if (account?.profile) {
agg[userId] = {
email: account.profile.email ?? "",
emailVerified: account.profile.emailVerified ?? false,
name: account.profile.name,
};
}
return agg;
},
{} as Record<string, { email: string; emailVerified: boolean; name: string | undefined }>,
);
await helper.setToGlobal(ACCOUNT_ACCOUNTS, accountsToStore);
await helper.remove("authenticatedAccounts");
}
private async migrateAccountActivity(helper: MigrationHelper) {
const stored = await helper.get<Record<string, Date>>("accountActivity");
const accountActivity = Object.entries(stored ?? {}).reduce(
(agg, [userId, dateMs]) => {
agg[userId] = JSON.stringify(new Date(dateMs));
return agg;
},
{} as Record<string, string>,
);
await helper.setToGlobal(ACCOUNT_ACTIVITY, accountActivity);
await helper.remove("accountActivity");
}
private async migrateActiveAccountId(helper: MigrationHelper) {
const activeAccountId = await helper.get<string>("activeUserId");
await helper.setToGlobal(ACCOUNT_ACTIVE_ACCOUNT_ID, activeAccountId);
await helper.remove("activeUserId");
}
}

View File

@@ -62,6 +62,7 @@ describe("SendService", () => {
accountService.activeAccountSubject.next({ accountService.activeAccountSubject.next({
id: mockUserId, id: mockUserId,
email: "email", email: "email",
emailVerified: false,
name: "name", name: "name",
}); });

View File

@@ -326,7 +326,10 @@ export class SyncService implements SyncServiceAbstraction {
await this.cryptoService.setOrgKeys(response.organizations, response.providerOrganizations); await this.cryptoService.setOrgKeys(response.organizations, response.providerOrganizations);
await this.avatarService.setSyncAvatarColor(response.id as UserId, response.avatarColor); await this.avatarService.setSyncAvatarColor(response.id as UserId, response.avatarColor);
await this.tokenService.setSecurityStamp(response.securityStamp, response.id as UserId); await this.tokenService.setSecurityStamp(response.securityStamp, response.id as UserId);
await this.stateService.setEmailVerified(response.emailVerified); await this.accountService.setAccountEmailVerified(
response.id as UserId,
response.emailVerified,
);
await this.billingAccountProfileStateService.setHasPremium( await this.billingAccountProfileStateService.setHasPremium(
response.premiumPersonally, response.premiumPersonally,