diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.html b/apps/browser/src/auth/popup/account-switching/account-switcher.component.html new file mode 100644 index 0000000000..bde9c3f6a0 --- /dev/null +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.html @@ -0,0 +1,7 @@ +
+
+ +
+
diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts new file mode 100644 index 0000000000..8d4777c30d --- /dev/null +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts @@ -0,0 +1,20 @@ +import { Component } from "@angular/core"; +import { Router } from "@angular/router"; + +import { AccountSwitcherService } from "../services/account-switcher.service"; + +@Component({ + templateUrl: "account-switcher.component.html", +}) +export class AccountSwitcherComponent { + constructor(private accountSwitcherService: AccountSwitcherService, private router: Router) {} + + get accountOptions$() { + return this.accountSwitcherService.accountOptions$; + } + + async selectAccount(id: string) { + await this.accountSwitcherService.selectAccount(id); + this.router.navigate(["/home"]); + } +} diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.html b/apps/browser/src/auth/popup/account-switching/current-account.component.html new file mode 100644 index 0000000000..bb482347e7 --- /dev/null +++ b/apps/browser/src/auth/popup/account-switching/current-account.component.html @@ -0,0 +1,5 @@ +
+
+ +
+
diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.ts b/apps/browser/src/auth/popup/account-switching/current-account.component.ts new file mode 100644 index 0000000000..cf50ab2798 --- /dev/null +++ b/apps/browser/src/auth/popup/account-switching/current-account.component.ts @@ -0,0 +1,20 @@ +import { Component } from "@angular/core"; +import { Router } from "@angular/router"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; + +@Component({ + selector: "app-current-account", + templateUrl: "current-account.component.html", +}) +export class CurrentAccountComponent { + constructor(private accountService: AccountService, private router: Router) {} + + get currentAccount$() { + return this.accountService.activeAccount$; + } + + currentAccountClicked() { + this.router.navigate(["/account-switcher"]); + } +} diff --git a/apps/browser/src/auth/popup/services/account-switcher.service.spec.ts b/apps/browser/src/auth/popup/services/account-switcher.service.spec.ts new file mode 100644 index 0000000000..d60166e9b4 --- /dev/null +++ b/apps/browser/src/auth/popup/services/account-switcher.service.spec.ts @@ -0,0 +1,106 @@ +import { matches, mock } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom, timeout } from "rxjs"; + +import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { AccountSwitcherService } from "./account-switcher.service"; + +describe("AccountSwitcherService", () => { + const accountsSubject = new BehaviorSubject>(null); + const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(null); + + const accountService = mock(); + const stateService = mock(); + const messagingService = mock(); + + let accountSwitcherService: AccountSwitcherService; + + beforeEach(() => { + jest.resetAllMocks(); + accountService.accounts$ = accountsSubject; + accountService.activeAccount$ = activeAccountSubject; + accountSwitcherService = new AccountSwitcherService( + accountService, + stateService, + messagingService + ); + }); + + describe("accountOptions$", () => { + it("should return all accounts and an add account option when accounts are less than 5", async () => { + const user1AccountInfo: AccountInfo = { + name: "Test User 1", + email: "test1@email.com", + status: AuthenticationStatus.Unlocked, + }; + + accountsSubject.next({ + "1": user1AccountInfo, + } as Record); + + activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "1" as UserId })); + + const accounts = await firstValueFrom( + accountSwitcherService.accountOptions$.pipe(timeout(20)) + ); + expect(accounts).toHaveLength(2); + expect(accounts[0].id).toBe("1"); + expect(accounts[0].isSelected).toBeTruthy(); + + expect(accounts[1].id).toBe("addAccount"); + expect(accounts[1].isSelected).toBeFalsy(); + }); + + it.each([5, 6])( + "should return only accounts if there are %i accounts", + async (numberOfAccounts) => { + const seedAccounts: Record = {}; + for (let i = 0; i < numberOfAccounts; i++) { + seedAccounts[`${i}` as UserId] = { + email: `test${i}@email.com`, + name: "Test User ${i}", + status: AuthenticationStatus.Unlocked, + }; + } + accountsSubject.next(seedAccounts); + activeAccountSubject.next( + Object.assign(seedAccounts["1" as UserId], { id: "1" as UserId }) + ); + + const accounts = await firstValueFrom(accountSwitcherService.accountOptions$); + + expect(accounts).toHaveLength(numberOfAccounts); + accounts.forEach((account) => { + expect(account.id).not.toBe("addAccount"); + }); + } + ); + }); + + describe("selectAccount", () => { + it("initiates an add account logic when add account is selected", async () => { + await accountSwitcherService.selectAccount("addAccount"); + + expect(stateService.setActiveUser).toBeCalledWith(null); + expect(stateService.setRememberedEmail).toBeCalledWith(null); + + expect(accountService.switchAccount).not.toBeCalled(); + }); + + it("initiates an account switch with an account id", async () => { + await accountSwitcherService.selectAccount("1"); + + expect(accountService.switchAccount).toBeCalledWith("1"); + expect(messagingService.send).toBeCalledWith( + "switchAccount", + matches((payload) => { + return payload.userId === "1"; + }) + ); + }); + }); +}); diff --git a/apps/browser/src/auth/popup/services/account-switcher.service.ts b/apps/browser/src/auth/popup/services/account-switcher.service.ts new file mode 100644 index 0000000000..6614ccec1d --- /dev/null +++ b/apps/browser/src/auth/popup/services/account-switcher.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from "@angular/core"; +import { combineLatest, map } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { UserId } from "@bitwarden/common/types/guid"; + +const SPECIAL_ADD_ACCOUNT_VALUE = "addAccount"; + +@Injectable({ + providedIn: "root", +}) +export class AccountSwitcherService { + constructor( + private accountService: AccountService, + private stateService: StateService, + private messagingService: MessagingService + ) {} + + get accountOptions$() { + return combineLatest([this.accountService.accounts$, this.accountService.activeAccount$]).pipe( + map(([accounts, activeAccount]) => { + const accountEntries = Object.entries(accounts); + // Accounts shouldn't ever be more than 5 but just in case do a greater than + const hasMaxAccounts = accountEntries.length >= 5; + const options: { name: string; id: string; isSelected: boolean }[] = accountEntries.map( + ([id, account]) => { + return { + name: account.name ?? account.email, + id: id, + isSelected: id === activeAccount?.id, + }; + } + ); + + if (!hasMaxAccounts) { + options.push({ + name: "Add Account", + id: SPECIAL_ADD_ACCOUNT_VALUE, + isSelected: activeAccount?.id == null, + }); + } + + return options; + }) + ); + } + + async selectAccount(id: string) { + if (id === SPECIAL_ADD_ACCOUNT_VALUE) { + await this.stateService.setActiveUser(null); + await this.stateService.setRememberedEmail(null); + return; + } + + this.accountService.switchAccount(id as UserId); + this.messagingService.send("switchAccount", { userId: id }); + } +} diff --git a/apps/browser/src/platform/popup/header.component.html b/apps/browser/src/platform/popup/header.component.html index 5cfdcc9969..9e37533017 100644 --- a/apps/browser/src/platform/popup/header.component.html +++ b/apps/browser/src/platform/popup/header.component.html @@ -6,8 +6,7 @@
- TODO: Current Account - +
diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index f1465296cb..54e600a574 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -12,6 +12,7 @@ import { canAccessFeature } from "@bitwarden/angular/guard/feature-flag.guard"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { fido2AuthGuard } from "../auth/guards/fido2-auth.guard"; +import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; import { EnvironmentComponent } from "../auth/popup/environment.component"; import { HintComponent } from "../auth/popup/hint.component"; import { HomeComponent } from "../auth/popup/home.component"; @@ -362,6 +363,11 @@ const routes: Routes = [ }, ], }, + { + path: "account-switcher", + component: AccountSwitcherComponent, + data: { state: "account-switcher" }, + }, ]; @Injectable() diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 3b8be00804..3aff12b4e3 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -15,7 +15,10 @@ import { BitwardenToastModule } from "@bitwarden/angular/components/toastr.compo import { JslibModule } from "@bitwarden/angular/jslib.module"; import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe"; import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe"; +import { AvatarModule } from "@bitwarden/components"; +import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; +import { CurrentAccountComponent } from "../auth/popup/account-switching/current-account.component"; import { SetPinComponent } from "../auth/popup/components/set-pin.component"; import { EnvironmentComponent } from "../auth/popup/environment.component"; import { HintComponent } from "../auth/popup/hint.component"; @@ -101,6 +104,7 @@ import "../platform/popup/locales"; ServicesModule, DialogModule, FilePopoutCalloutComponent, + AvatarModule, ], declarations: [ ActionButtonsComponent, @@ -161,6 +165,8 @@ import "../platform/popup/locales"; HelpAndFeedbackComponent, AutofillComponent, EnvironmentSelectorComponent, + CurrentAccountComponent, + AccountSwitcherComponent, ], providers: [CurrencyPipe, DatePipe], bootstrap: [AppComponent],