Browse Source
* Collate browser header concerns into component Header component has a slots field with a left, center, right, and a right-most location for a current account, which will link to an account switcher. * Use feature flag if OK for production eventually * Make sure centered content centered * Allow for disabling header theming for login page visual gitches exist for links and buttons, due to specifications futher down in the header, but those items shouldn't use the `no-theme` option. For now, it's just for the login screen * Add Account Switching Component * Collate browser header concerns into component Header component has a slots field with a left, center, right, and a right-most location for a current account, which will link to an account switcher. * Use feature flag if OK for production eventually * Add Account Switching Component * Fix Rebase Issues * Remove Comments * Move AccountSwitcher Logic Into Service * Rename File * Move Router to Component * Add Tests for AccountSwitcherService --------- Co-authored-by: Matt Gibson <mgibson@bitwarden.com>pull/6736/head
9 changed files with 231 additions and 2 deletions
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
<div *ngIf="accountOptions$ | async as accountOptions" class="box-content"> |
||||
<div *ngFor="let accountOption of accountOptions" class="box-content-row box-content-row-flex"> |
||||
<button type="button" (click)="selectAccount(accountOption.id)" class="row-main"> |
||||
{{ accountOption.name }} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
@ -0,0 +1,20 @@
@@ -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"]); |
||||
} |
||||
} |
||||
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
<div *ngIf="currentAccount$ | async as currentAccount"> |
||||
<div (click)="currentAccountClicked()" class="tw-mr-1 tw-mt-1"> |
||||
<bit-avatar [id]="currentAccount.id" [text]="currentAccount.name"></bit-avatar> |
||||
</div> |
||||
</div> |
||||
@ -0,0 +1,20 @@
@@ -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"]); |
||||
} |
||||
} |
||||
@ -0,0 +1,106 @@
@@ -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<Record<UserId, AccountInfo>>(null); |
||||
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(null); |
||||
|
||||
const accountService = mock<AccountService>(); |
||||
const stateService = mock<StateService>(); |
||||
const messagingService = mock<MessagingService>(); |
||||
|
||||
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<UserId, AccountInfo>); |
||||
|
||||
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<UserId, AccountInfo> = {}; |
||||
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"; |
||||
}) |
||||
); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,60 @@
@@ -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 }); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue