mirror of
https://github.com/bitwarden/browser.git
synced 2024-09-30 04:28:19 +02: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">
|
<div class="right">
|
||||||
<ng-content select=".right"></ng-content>
|
<ng-content select=".right"></ng-content>
|
||||||
<ng-container *ngIf="authedAccounts$ | async">
|
<ng-container *ngIf="authedAccounts$ | async">
|
||||||
TODO: Current Account
|
<app-current-account></app-current-account>
|
||||||
<!-- <app-current-account></app-current-account> -->
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
|
||||||
import { fido2AuthGuard } from "../auth/guards/fido2-auth.guard";
|
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 { EnvironmentComponent } from "../auth/popup/environment.component";
|
||||||
import { HintComponent } from "../auth/popup/hint.component";
|
import { HintComponent } from "../auth/popup/hint.component";
|
||||||
import { HomeComponent } from "../auth/popup/home.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()
|
@Injectable()
|
||||||
|
@ -15,7 +15,10 @@ import { BitwardenToastModule } from "@bitwarden/angular/components/toastr.compo
|
|||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe";
|
import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe";
|
||||||
import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.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 { SetPinComponent } from "../auth/popup/components/set-pin.component";
|
||||||
import { EnvironmentComponent } from "../auth/popup/environment.component";
|
import { EnvironmentComponent } from "../auth/popup/environment.component";
|
||||||
import { HintComponent } from "../auth/popup/hint.component";
|
import { HintComponent } from "../auth/popup/hint.component";
|
||||||
@ -101,6 +104,7 @@ import "../platform/popup/locales";
|
|||||||
ServicesModule,
|
ServicesModule,
|
||||||
DialogModule,
|
DialogModule,
|
||||||
FilePopoutCalloutComponent,
|
FilePopoutCalloutComponent,
|
||||||
|
AvatarModule,
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
ActionButtonsComponent,
|
ActionButtonsComponent,
|
||||||
@ -161,6 +165,8 @@ import "../platform/popup/locales";
|
|||||||
HelpAndFeedbackComponent,
|
HelpAndFeedbackComponent,
|
||||||
AutofillComponent,
|
AutofillComponent,
|
||||||
EnvironmentSelectorComponent,
|
EnvironmentSelectorComponent,
|
||||||
|
CurrentAccountComponent,
|
||||||
|
AccountSwitcherComponent,
|
||||||
],
|
],
|
||||||
providers: [CurrencyPipe, DatePipe],
|
providers: [CurrencyPipe, DatePipe],
|
||||||
bootstrap: [AppComponent],
|
bootstrap: [AppComponent],
|
||||||
|
Loading…
Reference in New Issue
Block a user