import { Injectable } from "@angular/core"; import { Observable, combineLatest, filter, firstValueFrom, map, switchMap, throwError, timeout, } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { UserId } from "@bitwarden/common/types/guid"; import { fromChromeEvent } from "../../../../platform/browser/from-chrome-event"; export type AvailableAccount = { name: string; email?: string; id: string; isActive: boolean; server?: string; status?: AuthenticationStatus; avatarColor?: string; }; @Injectable({ providedIn: "root", }) export class AccountSwitcherService { static incompleteAccountSwitchError = "Account switch did not complete."; ACCOUNT_LIMIT = 5; SPECIAL_ADD_ACCOUNT_ID = "addAccount"; availableAccounts$: Observable; switchAccountFinished$: Observable; constructor( private accountService: AccountService, private avatarService: AvatarService, private messagingService: MessagingService, private environmentService: EnvironmentService, private logService: LogService, authService: AuthService, ) { this.availableAccounts$ = combineLatest([ accountService.accounts$, authService.authStatuses$, this.accountService.activeAccount$, ]).pipe( switchMap(async ([accounts, accountStatuses, activeAccount]) => { const loggedInIds = Object.keys(accounts).filter( (id: UserId) => accountStatuses[id] !== AuthenticationStatus.LoggedOut, ); // Accounts shouldn't ever be more than ACCOUNT_LIMIT but just in case do a greater than const hasMaxAccounts = loggedInIds.length >= this.ACCOUNT_LIMIT; const options: AvailableAccount[] = await Promise.all( loggedInIds.map(async (id: UserId) => { return { name: accounts[id].name ?? accounts[id].email, email: accounts[id].email, id: id, server: (await this.environmentService.getEnvironment(id))?.getHostname(), status: accountStatuses[id], isActive: id === activeAccount?.id, avatarColor: await firstValueFrom( this.avatarService.getUserAvatarColor$(id as UserId), ), }; }), ); if (!hasMaxAccounts) { options.push({ name: "Add account", id: this.SPECIAL_ADD_ACCOUNT_ID, isActive: false, }); } return options.sort((a, b) => { /** * Make sure the compare function is "well-formed" to account for browser inconsistencies. * * For specifics, see the sections "Description" and "Sorting with a non-well-formed comparator" * on this page: * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort */ // Active account (if one exists) is always first if (a.isActive) { return -1; } // If account "b" is the 'Add account' button, keep original order of "a" and "b" if (b.id === this.SPECIAL_ADD_ACCOUNT_ID) { return 0; } return 1; }); }), ); // Create a reusable observable that listens to the switchAccountFinish message and returns the userId from the message this.switchAccountFinished$ = fromChromeEvent<[message: { command: string; userId: string }]>( chrome.runtime.onMessage, ).pipe( filter(([message]) => message.command === "switchAccountFinish"), map(([message]) => message.userId), ); } get specialAccountAddId() { return this.SPECIAL_ADD_ACCOUNT_ID; } async selectAccount(id: string) { if (id === this.SPECIAL_ADD_ACCOUNT_ID) { id = null; } // Creates a subscription to the switchAccountFinished observable but further // filters it to only care about the current userId. const switchAccountFinishedPromise = firstValueFrom( this.switchAccountFinished$.pipe( filter((userId) => userId === id), timeout({ // Much longer than account switching is expected to take for normal accounts // but the account switching process includes a possible full sync so we need to account // for very large accounts and want to still have a timeout // to avoid a promise that might never resolve/reject first: 60_000, with: () => throwError(() => new Error(AccountSwitcherService.incompleteAccountSwitchError)), }), ), ); // Initiate the actions required to make account switching happen await this.accountService.switchAccount(id as UserId); this.messagingService.send("switchAccount", { userId: id }); // This message should cause switchAccountFinish to be sent // Wait until we recieve the switchAccountFinished message await switchAccountFinishedPromise.catch((err) => { if ( err instanceof Error && err.message === AccountSwitcherService.incompleteAccountSwitchError ) { this.logService.warning("message 'switchAccount' never responded."); return; } throw err; }); } }