1
0
mirror of https://github.com/bitwarden/browser synced 2026-03-02 11:31:44 +00:00

[PM 29776] Move Account Switcher (#18686)

* moved account switcher into page header

* Added 'header-actions' slot for AnonLayout

* removed 'standalone: true'

* cleanup

* more cleanup

* fixed lint error

* use 'app-avatar' in dropdown

* fixed account avatar vertical centering

* revert back to `standalone: false`

* moved new account switcher behind 'desktop-ui-migration-milestone-4' FF

* - added to default FF
- fixed template formatting
- removed type of 'configService'
- renamed to 'account-switcher-v2'

* fixed test

* moved 'account-switcher-v2' into '/auth'

* updated class and style from 'v3' to 'v2'

* removed ts-strict-ignore

* added back `ts-strict-ignore`

* added `aria-label` to `bit-avatar`

* fixed page load issue after clearing local session data

* fixed 'Access denied' toast appearing when milestone4 FF is false

* fixed new account switcher missing from 'locked vaults' and other 'AnonLayouts'

* fixed styles for non-logged in account-switcher button

* fixed duplicate account switcher bug

* lint fix

* updated 'account switcher' anon layout styles/colors

* fixed color from `bg-brand-strong` to `bg-brand`

---------

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
This commit is contained in:
Leslie Xiong
2026-02-28 14:57:57 -05:00
committed by GitHub
parent 48b8c5b807
commit cfa4b1c27f
11 changed files with 522 additions and 13 deletions

View File

@@ -48,6 +48,7 @@ import {
RemovePasswordComponent,
} from "@bitwarden/key-management-ui";
import { AccountSwitcherV2Component } from "../auth/components/account-switcher/account-switcher-v2.component";
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
import { reactiveUnlockVaultGuard } from "../autofill/guards/reactive-vault-guard";
import { Fido2CreateComponent } from "../autofill/modal/credentials/fido2-create.component";
@@ -84,6 +85,11 @@ const routes: Routes = [
path: "",
component: AuthenticationTimeoutComponent,
},
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
data: {
pageIcon: TwoFactorTimeoutIcon,
@@ -96,7 +102,14 @@ const routes: Routes = [
path: AuthRoute.NewDeviceVerification,
component: AnonLayoutWrapperComponent,
canActivate: [unauthGuardFn(), activeAuthGuard()],
children: [{ path: "", component: NewDeviceVerificationComponent }],
children: [
{ path: "", component: NewDeviceVerificationComponent },
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
data: {
pageIcon: TwoFactorAuthEmailIcon,
pageTitle: {
@@ -160,6 +173,11 @@ const routes: Routes = [
loginRoute: `/${AuthRoute.Login}`,
} satisfies RegistrationStartSecondaryComponentData,
},
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
},
{
@@ -173,6 +191,11 @@ const routes: Routes = [
path: "",
component: RegistrationFinishComponent,
},
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
},
{
@@ -192,6 +215,11 @@ const routes: Routes = [
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
},
{
@@ -200,7 +228,14 @@ const routes: Routes = [
data: {
pageIcon: DevicesIcon,
},
children: [{ path: "", component: LoginDecryptionOptionsComponent }],
children: [
{ path: "", component: LoginDecryptionOptionsComponent },
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
},
{
path: AuthRoute.Sso,
@@ -220,6 +255,11 @@ const routes: Routes = [
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
},
{
@@ -240,6 +280,11 @@ const routes: Routes = [
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
},
{
@@ -253,7 +298,14 @@ const routes: Routes = [
key: "adminApprovalRequestSentToAdmins",
},
} satisfies AnonLayoutWrapperData,
children: [{ path: "", component: LoginViaAuthRequestComponent }],
children: [
{ path: "", component: LoginViaAuthRequestComponent },
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
},
{
path: AuthRoute.PasswordHint,
@@ -274,6 +326,11 @@ const routes: Routes = [
component: EnvironmentSelectorComponent,
outlet: "environment-selector",
},
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
},
{
@@ -291,6 +348,11 @@ const routes: Routes = [
path: "",
component: LockComponent,
},
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
},
{
@@ -301,6 +363,11 @@ const routes: Routes = [
path: "",
component: TwoFactorAuthComponent,
},
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
data: {
pageTitle: {
@@ -313,23 +380,42 @@ const routes: Routes = [
{
path: AuthRoute.SetInitialPassword,
canActivate: [authGuard],
component: SetInitialPasswordComponent,
data: {
maxWidth: "lg",
pageIcon: LockIcon,
} satisfies AnonLayoutWrapperData,
children: [
{
path: "",
component: SetInitialPasswordComponent,
},
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
},
{
path: AuthRoute.ChangePassword,
component: ChangePasswordComponent,
canActivate: [authGuard],
data: {
pageIcon: LockIcon,
} satisfies AnonLayoutWrapperData,
children: [
{
path: "",
component: ChangePasswordComponent,
},
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
},
{
path: "remove-password",
component: RemovePasswordComponent,
canActivate: [authGuard],
data: {
pageTitle: {
@@ -337,10 +423,20 @@ const routes: Routes = [
},
pageIcon: LockIcon,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
path: "",
component: RemovePasswordComponent,
},
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
},
{
path: "confirm-key-connector-domain",
component: ConfirmKeyConnectorDomainComponent,
canActivate: [],
data: {
pageTitle: {
@@ -348,6 +444,17 @@ const routes: Routes = [
},
pageIcon: DomainIcon,
} satisfies RouteDataProperties & AnonLayoutWrapperData,
children: [
{
path: "",
component: ConfirmKeyConnectorDomainComponent,
},
{
path: "",
component: AccountSwitcherV2Component,
outlet: "header-actions",
},
],
},
],
},

View File

@@ -491,9 +491,10 @@ export class AppComponent implements OnInit, OnDestroy {
this.messagingService.send("unlocked");
this.loading = true;
await this.syncService.fullSync(false);
this.loading = false;
// Force reload to ensure route guards are activated
await this.router.navigate(["vault"], { onSameUrlNavigation: "reload" });
// Clear loading after navigating to avoid flickering the previous route
this.loading = false;
}
this.messagingService.send("finishSwitchAccount");
break;

View File

@@ -1,4 +1,7 @@
<div class="header">
<app-search></app-search>
<app-account-switcher></app-account-switcher>
@if (!useNewAccountSwitcher()) {
<app-account-switcher></app-account-switcher>
}
</div>

View File

@@ -1,4 +1,8 @@
import { Component } from "@angular/core";
import { Component, inject } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -7,4 +11,11 @@ import { Component } from "@angular/core";
templateUrl: "header.component.html",
standalone: false,
})
export class HeaderComponent {}
export class HeaderComponent {
private configService: ConfigService = inject(ConfigService);
protected readonly useNewAccountSwitcher = toSignal(
this.configService.getFeatureFlag$(FeatureFlag.DesktopUiMigrationMilestone4),
{ initialValue: false },
);
}

View File

@@ -16,4 +16,6 @@
<ng-container slot="tabs">
<ng-content select="[slot=tabs]" />
</ng-container>
<app-account-switcher-v2 />
</bit-header>

View File

@@ -1,3 +1,4 @@
import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute } from "@angular/router";
import { mock } from "jest-mock-extended";
@@ -6,6 +7,8 @@ import { of } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { HeaderComponent } from "@bitwarden/components";
import { AccountSwitcherV2Component } from "../../../auth/components/account-switcher/account-switcher-v2.component";
import { DesktopHeaderComponent } from "./desktop-header.component";
describe("DesktopHeaderComponent", () => {
@@ -34,7 +37,12 @@ describe("DesktopHeaderComponent", () => {
useValue: mockActivatedRoute,
},
],
}).compileComponents();
})
.overrideComponent(DesktopHeaderComponent, {
remove: { imports: [AccountSwitcherV2Component] },
add: { schemas: [CUSTOM_ELEMENTS_SCHEMA] },
})
.compileComponents();
fixture = TestBed.createComponent(DesktopHeaderComponent);
component = fixture.componentInstance;

View File

@@ -6,10 +6,12 @@ import { map } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { HeaderComponent, BannerModule } from "@bitwarden/components";
import { AccountSwitcherV2Component } from "../../../auth/components/account-switcher/account-switcher-v2.component";
@Component({
selector: "app-header",
templateUrl: "./desktop-header.component.html",
imports: [BannerModule, HeaderComponent],
imports: [BannerModule, HeaderComponent, AccountSwitcherV2Component],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DesktopHeaderComponent {

View File

@@ -0,0 +1,112 @@
<ng-container *ngIf="view$ | async as view">
<div
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
[hidden]="!view.showSwitcher || !useNewAccountSwitcher()"
>
@if (view.activeAccount?.email != null) {
<button
class="account-switcher-v2"
type="button"
(click)="toggle()"
[disabled]="disabled"
aria-haspopup="dialog"
>
<bit-avatar
[text]="view.activeAccount.name ?? view.activeAccount.email"
[id]="view.activeAccount.id"
[color]="view.activeAccount.avatarColor"
[attr.aria-label]="view.activeAccount.name ?? view.activeAccount.email"
></bit-avatar>
</button>
} @else {
<div class="tw-rounded-full tw-bg-bg-brand">
<button
class="account-switcher-v2 !tw-rounded-full"
type="button"
buttonType="contrast"
bitIconButton="bwi-ellipsis-h"
[label]="'switchAccount' | i18n"
(click)="toggle()"
[disabled]="disabled"
aria-haspopup="dialog"
></button>
</div>
}
</div>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
(backdropClick)="close()"
(detach)="close()"
[cdkConnectedOverlayOpen]="view.showSwitcher && isOpen"
[cdkConnectedOverlayPositions]="overlayPosition"
cdkConnectedOverlayMinWidth="250px"
>
<div
class="account-switcher-dropdown"
[@transformPanel]="'open'"
cdkTrapFocus
cdkTrapFocusAutoCapture
role="dialog"
aria-modal="true"
>
<div class="accounts" *ngIf="view.numberOfAccounts > 0">
<button
type="button"
*ngFor="let account of view.inactiveAccounts | keyvalue"
class="account account-v3"
(click)="switch(account.key)"
>
<div class="tw-flex tw-gap-4 tw-items-center">
@if (account.value.email != null) {
<bit-avatar
[text]="account.value.name ?? account.value.email"
[id]="account.value.id"
[color]="account.value.avatarColor"
aria-hidden="true"
></bit-avatar>
}
<div class="accountInfo">
<span class="sr-only">{{ "switchAccount" | i18n }}:&nbsp;</span>
<span class="email">{{ account.value.email }}</span>
<span class="server">
<span class="sr-only"> / </span>{{ account.value.server }}
</span>
<span class="status">
<span class="sr-only">&nbsp;(</span
>{{
(account.value.authenticationStatus === authStatus.Unlocked
? "unlocked"
: "locked"
) | i18n
}}<span class="sr-only">)</span></span
>
</div>
</div>
<i
class="bwi bwi-2x text-muted"
[ngClass]="
account.value.authenticationStatus === authStatus.Unlocked ? 'bwi-unlock' : 'bwi-lock'
"
aria-hidden="true"
></i>
</button>
</div>
<ng-container *ngIf="view.activeAccount">
<div class="border" *ngIf="view.numberOfAccounts > 0"></div>
<ng-container *ngIf="view.numberOfAccounts < 4">
<button type="button" class="add" (click)="addAccount()">
<i class="bwi bwi-plus" aria-hidden="true"></i> {{ "addAccount" | i18n }}
</button>
</ng-container>
<ng-container *ngIf="view.numberOfAccounts === 4">
<span class="accountLimitReached">{{ "accountSwitcherLimitReached" | i18n }} </span>
</ng-container>
</ng-container>
</div>
</ng-template>
</ng-container>

View File

@@ -0,0 +1,246 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { animate, state, style, transition, trigger } from "@angular/animations";
import { A11yModule } from "@angular/cdk/a11y";
import { OverlayModule, ConnectedPosition } from "@angular/cdk/overlay";
import { CommonModule } from "@angular/common";
import { Component, OnInit } from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging";
import { UserId } from "@bitwarden/common/types/guid";
import { AvatarModule, IconButtonModule } from "@bitwarden/components";
import { DesktopBiometricsService } from "../../../key-management/biometrics/desktop.biometrics.service";
type ActiveAccount = {
id: string;
name: string;
email: string;
avatarColor: string;
server: string;
};
type InactiveAccount = ActiveAccount & {
authenticationStatus: AuthenticationStatus;
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-account-switcher-v2",
templateUrl: "account-switcher-v2.component.html",
imports: [CommonModule, OverlayModule, A11yModule, JslibModule, AvatarModule, IconButtonModule],
animations: [
trigger("transformPanel", [
state(
"void",
style({
opacity: 0,
}),
),
transition(
"void => open",
animate(
"100ms linear",
style({
opacity: 1,
}),
),
),
transition("* => void", animate("100ms linear", style({ opacity: 0 }))),
]),
],
})
export class AccountSwitcherV2Component implements OnInit {
protected readonly useNewAccountSwitcher = toSignal(
this.configService.getFeatureFlag$(FeatureFlag.DesktopUiMigrationMilestone4),
{ initialValue: false },
);
activeAccount$: Observable<ActiveAccount | null>;
inactiveAccounts$: Observable<{ [userId: string]: InactiveAccount }>;
authStatus = AuthenticationStatus;
view$: Observable<{
activeAccount: ActiveAccount | null;
inactiveAccounts: { [userId: string]: InactiveAccount };
numberOfAccounts: number;
showSwitcher: boolean;
}>;
isOpen = false;
overlayPosition: ConnectedPosition[] = [
{
originX: "end",
originY: "bottom",
overlayX: "end",
overlayY: "top",
},
];
showSwitcher$: Observable<boolean>;
numberOfAccounts$: Observable<number>;
disabled = false;
constructor(
private authService: AuthService,
private avatarService: AvatarService,
private messagingService: MessagingService,
private messageListener: MessageListener,
private router: Router,
private environmentService: EnvironmentService,
private accountService: AccountService,
private biometricsService: DesktopBiometricsService,
private configService: ConfigService,
) {
this.activeAccount$ = this.accountService.activeAccount$.pipe(
switchMap(async (active) => {
if (active == null) {
return null;
}
if (!active.name && !active.email) {
// We need to have this information at minimum to display them.
return null;
}
return {
id: active.id,
name: active.name,
email: active.email,
avatarColor: await firstValueFrom(this.avatarService.avatarColor$),
server: (
await firstValueFrom(this.environmentService.getEnvironment$(active.id))
)?.getHostname(),
};
}),
);
this.inactiveAccounts$ = combineLatest([
this.activeAccount$,
this.accountService.accounts$,
this.authService.authStatuses$,
]).pipe(
switchMap(async ([activeAccount, accounts, accountStatuses]) => {
// Filter out logged out accounts and active account
accounts = Object.fromEntries(
Object.entries(accounts).filter(
([id]: [UserId, AccountInfo]) =>
accountStatuses[id] !== AuthenticationStatus.LoggedOut || id === activeAccount?.id,
),
);
return this.createInactiveAccounts(accounts);
}),
);
this.showSwitcher$ = combineLatest([this.activeAccount$, this.inactiveAccounts$]).pipe(
map(([activeAccount, inactiveAccounts]) => {
const hasActiveUser = activeAccount != null;
const userIsAddingAnAdditionalAccount = Object.keys(inactiveAccounts).length > 0;
return hasActiveUser || userIsAddingAnAdditionalAccount;
}),
);
this.numberOfAccounts$ = this.inactiveAccounts$.pipe(
map((accounts) => Object.keys(accounts).length),
);
this.view$ = combineLatest([
this.activeAccount$,
this.inactiveAccounts$,
this.numberOfAccounts$,
this.showSwitcher$,
]).pipe(
map(([activeAccount, inactiveAccounts, numberOfAccounts, showSwitcher]) => ({
activeAccount,
inactiveAccounts,
numberOfAccounts,
showSwitcher,
})),
);
}
async ngOnInit() {
const active = await firstValueFrom(this.accountService.activeAccount$);
if (active == null) {
return;
}
const authStatus = await firstValueFrom(
this.authService.authStatuses$.pipe(map((statuses) => statuses[active.id])),
);
if (authStatus === AuthenticationStatus.LoggedOut) {
const nextUpAccount = await firstValueFrom(this.accountService.nextUpAccount$);
if (nextUpAccount != null) {
await this.switch(nextUpAccount.id);
} else {
await this.addAccount();
}
}
}
toggle() {
this.isOpen = !this.isOpen;
}
close() {
this.isOpen = false;
}
async switch(userId: string) {
this.close();
await this.biometricsService.setShouldAutopromptNow(true);
this.disabled = true;
const accountSwitchFinishedPromise = firstValueFrom(
this.messageListener.messages$(new CommandDefinition("finishSwitchAccount")),
);
this.messagingService.send("switchAccount", { userId });
await accountSwitchFinishedPromise;
this.disabled = false;
}
async addAccount() {
this.close();
await this.accountService.switchAccount(null);
await this.router.navigate(["/login"]);
}
private async createInactiveAccounts(baseAccounts: {
[userId: string]: AccountInfo;
}): Promise<{ [userId: string]: InactiveAccount }> {
const inactiveAccounts: { [userId: string]: InactiveAccount } = {};
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
for (const userId in baseAccounts) {
if (userId == null || userId === activeUserId) {
continue;
}
inactiveAccounts[userId] = {
id: userId,
name: baseAccounts[userId].name,
email: baseAccounts[userId].email,
authenticationStatus: await this.authService.getAuthStatus(userId),
avatarColor: await firstValueFrom(this.avatarService.getUserAvatarColor$(userId as UserId)),
server: (
await firstValueFrom(this.environmentService.getEnvironment$(userId as UserId))
)?.getHostname(),
};
}
return inactiveAccounts;
}
}

View File

@@ -145,6 +145,16 @@
}
}
.account-switcher-v2 {
display: flex;
align-items: center;
span {
display: flex;
align-items: center;
}
}
.account-switcher-dropdown {
@include themify($themes) {
background-color: themed("accountSwitcherBackgroundColor");
@@ -206,6 +216,11 @@
}
}
}
.account-v3 {
display: flex;
justify-content: space-between;
}
}
.border {

View File

@@ -88,6 +88,7 @@ export enum FeatureFlag {
DesktopUiMigrationMilestone1 = "desktop-ui-migration-milestone-1",
DesktopUiMigrationMilestone2 = "desktop-ui-migration-milestone-2",
DesktopUiMigrationMilestone3 = "desktop-ui-migration-milestone-3",
DesktopUiMigrationMilestone4 = "desktop-ui-migration-milestone-4",
/* UIF */
RouterFocusManagement = "router-focus-management",
@@ -185,6 +186,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.DesktopUiMigrationMilestone1]: FALSE,
[FeatureFlag.DesktopUiMigrationMilestone2]: FALSE,
[FeatureFlag.DesktopUiMigrationMilestone3]: FALSE,
[FeatureFlag.DesktopUiMigrationMilestone4]: FALSE,
/* UIF */
[FeatureFlag.RouterFocusManagement]: FALSE,