1
0
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:
Justin Baur 2023-10-30 11:34:42 -04:00 committed by GitHub
parent 716a65a357
commit d650e94a61
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 231 additions and 2 deletions

View File

@ -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>

View File

@ -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"]);
}
}

View File

@ -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>

View File

@ -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"]);
}
}

View File

@ -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";
})
);
});
});
});

View File

@ -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 });
}
}

View File

@ -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>

View File

@ -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()

View File

@ -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],