mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-21 16:18:28 +01:00
[PM-2910] Add Account Switching Page (#6701)
* 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>
This commit is contained in:
parent
716a65a357
commit
d650e94a61
@ -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 @@
|
||||
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 @@
|
||||
<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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 });
|
||||
}
|
||||
}
|
@ -6,8 +6,7 @@
|
||||
<div class="right">
|
||||
<ng-content select=".right"></ng-content>
|
||||
<ng-container *ngIf="authedAccounts$ | async">
|
||||
TODO: Current Account
|
||||
<!-- <app-current-account></app-current-account> -->
|
||||
<app-current-account></app-current-account>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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()
|
||||
|
@ -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],
|
||||
|
Loading…
Reference in New Issue
Block a user