1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 17:53:39 +00:00

[PM-6688] Use AccountService as account source (#8893)

* Use account service to track accounts and active account

* Remove state service active account Observables.

* Add email verified to account service

* Do not store account info on logged out accounts

* Add account activity tracking to account service

* Use last account activity from account service

* migrate or replicate account service data

* Add `AccountActivityService` that handles storing account last active data

* Move active and next active user to account service

* Remove authenticated accounts from state object

* Fold account activity into account service

* Fix builds

* Fix desktop app switch

* Fix logging out non active user

* Expand helper to handle new authenticated accounts location

* Prefer view observable to tons of async pipes

* Fix `npm run test:types`

* Correct user activity sorting test

* Be more precise about log out messaging

* Fix dev compare errors

All stored values are serializable, the next step wasn't necessary and was erroring on some types that lack `toString`.

* If the account in unlocked on load of lock component, navigate away from lock screen

* Handle no users case for auth service statuses

* Specify account to switch to

* Filter active account out of inactive accounts

* Prefer constructor init

* Improve comparator

* Use helper methods internally

* Fixup component tests

* Clarify name

* Ensure accounts object has only valid userIds

* Capitalize const values

* Prefer descriptive, single-responsibility guards

* Update libs/common/src/state-migrations/migrate.ts

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>

* Fix merge

* Add user Id validation

activity for undefined was being set, which was resulting in requests for the auth status of `"undefined"` (string) userId, due to key enumeration. These changes stop that at both locations, as well as account add for good measure.

---------

Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com>
This commit is contained in:
Matt Gibson
2024-04-30 09:13:02 -04:00
committed by GitHub
parent 61d079cc34
commit c70a5aa024
67 changed files with 1380 additions and 618 deletions

View File

@@ -1,110 +1,112 @@
<!-- Please remove this disable statement when editing this file! -->
<!-- eslint-disable @angular-eslint/template/button-has-type -->
<button
class="account-switcher"
(click)="toggle()"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
[hidden]="!showSwitcher"
aria-haspopup="dialog"
>
<ng-container *ngIf="activeAccount?.email != null; else noActiveAccount">
<app-avatar
[text]="activeAccount.name"
[id]="activeAccount.id"
[color]="activeAccount.avatarColor"
[size]="25"
[circle]="true"
[fontSize]="14"
[dynamic]="true"
*ngIf="activeAccount.email != null"
aria-hidden="true"
></app-avatar>
<div class="active-account">
<div>{{ activeAccount.email }}</div>
<span>{{ activeAccount.server }}</span>
<span class="sr-only">&nbsp;({{ "switchAccount" | i18n }})</span>
</div>
</ng-container>
<ng-template #noActiveAccount>
<span>{{ "switchAccount" | i18n }}</span>
</ng-template>
<i
class="bwi"
aria-hidden="true"
[ngClass]="{ 'bwi-angle-down': !isOpen, 'bwi-angle-up': isOpen }"
></i>
</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
(backdropClick)="close()"
(detach)="close()"
[cdkConnectedOverlayOpen]="showSwitcher && isOpen"
[cdkConnectedOverlayPositions]="overlayPosition"
cdkConnectedOverlayMinWidth="250px"
>
<div
class="account-switcher-dropdown"
[@transformPanel]="'open'"
cdkTrapFocus
cdkTrapFocusAutoCapture
role="dialog"
aria-modal="true"
<ng-container *ngIf="view$ | async as view">
<button
class="account-switcher"
(click)="toggle()"
cdkOverlayOrigin
#trigger="cdkOverlayOrigin"
[hidden]="!view.showSwitcher"
aria-haspopup="dialog"
>
<div class="accounts" *ngIf="numberOfAccounts > 0">
<button
*ngFor="let account of inactiveAccounts | keyvalue"
class="account"
(click)="switch(account.key)"
>
<app-avatar
[text]="account.value.name ?? account.value.email"
[id]="account.value.id"
[size]="25"
[circle]="true"
[fontSize]="14"
[dynamic]="true"
[color]="account.value.avatarColor"
*ngIf="account.value.email != null"
aria-hidden="true"
></app-avatar>
<div class="accountInfo">
<span class="sr-only">{{ "switchAccount" | i18n }}:&nbsp;</span>
<span class="email" aria-hidden="true">{{ account.value.email }}</span>
<span class="server" aria-hidden="true">
<span class="sr-only"> / </span>{{ account.value.server }}
</span>
<span class="status" aria-hidden="true"
><span class="sr-only">&nbsp;(</span
>{{
(account.value.authenticationStatus === authStatus.Unlocked ? "unlocked" : "locked")
| i18n
}}<span class="sr-only">)</span></span
>
</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="activeAccount?.email != null">
<div class="border" *ngIf="numberOfAccounts > 0"></div>
<ng-container *ngIf="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="numberOfAccounts === 4">
<span class="accountLimitReached">{{ "accountSwitcherLimitReached" | i18n }} </span>
</ng-container>
<ng-container *ngIf="view.activeAccount; else noActiveAccount">
<app-avatar
[text]="view.activeAccount.name ?? view.activeAccount.email"
[id]="view.activeAccount.id"
[color]="view.activeAccount.avatarColor"
[size]="25"
[circle]="true"
[fontSize]="14"
[dynamic]="true"
*ngIf="view.activeAccount.email != null"
aria-hidden="true"
></app-avatar>
<div class="active-account">
<div>{{ view.activeAccount.email }}</div>
<span>{{ view.activeAccount.server }}</span>
<span class="sr-only">&nbsp;({{ "switchAccount" | i18n }})</span>
</div>
</ng-container>
</div>
</ng-template>
<ng-template #noActiveAccount>
<span>{{ "switchAccount" | i18n }}</span>
</ng-template>
<i
class="bwi"
aria-hidden="true"
[ngClass]="{ 'bwi-angle-down': !isOpen, 'bwi-angle-up': isOpen }"
></i>
</button>
<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
*ngFor="let account of view.inactiveAccounts | keyvalue"
class="account"
(click)="switch(account.key)"
>
<app-avatar
[text]="account.value.name ?? account.value.email"
[id]="account.value.id"
[size]="25"
[circle]="true"
[fontSize]="14"
[dynamic]="true"
[color]="account.value.avatarColor"
*ngIf="account.value.email != null"
aria-hidden="true"
></app-avatar>
<div class="accountInfo">
<span class="sr-only">{{ "switchAccount" | i18n }}:&nbsp;</span>
<span class="email" aria-hidden="true">{{ account.value.email }}</span>
<span class="server" aria-hidden="true">
<span class="sr-only"> / </span>{{ account.value.server }}
</span>
<span class="status" aria-hidden="true"
><span class="sr-only">&nbsp;(</span
>{{
(account.value.authenticationStatus === authStatus.Unlocked ? "unlocked" : "locked")
| i18n
}}<span class="sr-only">)</span></span
>
</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

@@ -1,19 +1,17 @@
import { animate, state, style, transition, trigger } from "@angular/animations";
import { ConnectedPosition } from "@angular/cdk/overlay";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { Component } from "@angular/core";
import { Router } from "@angular/router";
import { concatMap, firstValueFrom, Subject, takeUntil } from "rxjs";
import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common";
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 { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { Account } from "@bitwarden/common/platform/models/domain/account";
import { UserId } from "@bitwarden/common/types/guid";
type ActiveAccount = {
@@ -52,12 +50,18 @@ type InactiveAccount = ActiveAccount & {
]),
],
})
export class AccountSwitcherComponent implements OnInit, OnDestroy {
activeAccount?: ActiveAccount;
inactiveAccounts: { [userId: string]: InactiveAccount } = {};
export class AccountSwitcherComponent {
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[] = [
{
@@ -68,21 +72,9 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
},
];
private destroy$ = new Subject<void>();
showSwitcher$: Observable<boolean>;
get showSwitcher() {
const userIsInAVault = !Utils.isNullOrWhitespace(this.activeAccount?.email);
const userIsAddingAnAdditionalAccount = Object.keys(this.inactiveAccounts).length > 0;
return userIsInAVault || userIsAddingAnAdditionalAccount;
}
get numberOfAccounts() {
if (this.inactiveAccounts == null) {
this.isOpen = false;
return 0;
}
return Object.keys(this.inactiveAccounts).length;
}
numberOfAccounts$: Observable<number>;
constructor(
private stateService: StateService,
@@ -90,37 +82,65 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
private avatarService: AvatarService,
private messagingService: MessagingService,
private router: Router,
private tokenService: TokenService,
private environmentService: EnvironmentService,
private loginEmailService: LoginEmailServiceAbstraction,
) {}
private accountService: AccountService,
) {
this.activeAccount$ = this.accountService.activeAccount$.pipe(
switchMap(async (active) => {
if (active == null) {
return null;
}
async ngOnInit(): Promise<void> {
this.stateService.accounts$
.pipe(
concatMap(async (accounts: { [userId: string]: Account }) => {
this.inactiveAccounts = await this.createInactiveAccounts(accounts);
return {
id: active.id,
name: active.name,
email: active.email,
avatarColor: await firstValueFrom(this.avatarService.avatarColor$),
server: (await this.environmentService.getEnvironment())?.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),
);
try {
this.activeAccount = {
id: await this.tokenService.getUserId(),
name: (await this.tokenService.getName()) ?? (await this.tokenService.getEmail()),
email: await this.tokenService.getEmail(),
avatarColor: await firstValueFrom(this.avatarService.avatarColor$),
server: (await this.environmentService.getEnvironment())?.getHostname(),
};
} catch {
this.activeAccount = undefined;
}
}),
takeUntil(this.destroy$),
)
.subscribe();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.view$ = combineLatest([
this.activeAccount$,
this.inactiveAccounts$,
this.numberOfAccounts$,
this.showSwitcher$,
]).pipe(
map(([activeAccount, inactiveAccounts, numberOfAccounts, showSwitcher]) => ({
activeAccount,
inactiveAccounts,
numberOfAccounts,
showSwitcher,
})),
);
}
toggle() {
@@ -144,11 +164,13 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
await this.loginEmailService.saveEmailSettings();
await this.router.navigate(["/login"]);
await this.stateService.setActiveUser(null);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
await this.stateService.clearDecryptedData(activeAccount?.id as UserId);
await this.accountService.switchAccount(null);
}
private async createInactiveAccounts(baseAccounts: {
[userId: string]: Account;
[userId: string]: AccountInfo;
}): Promise<{ [userId: string]: InactiveAccount }> {
const inactiveAccounts: { [userId: string]: InactiveAccount } = {};
@@ -159,8 +181,8 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
inactiveAccounts[userId] = {
id: userId,
name: baseAccounts[userId].profile.name,
email: baseAccounts[userId].profile.email,
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 this.environmentService.getEnvironment(userId))?.getHostname(),

View File

@@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core";
import { UntypedFormControl } from "@angular/forms";
import { Subscription } from "rxjs";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { SearchBarService, SearchBarState } from "./search-bar.service";
@@ -18,7 +18,7 @@ export class SearchComponent implements OnInit, OnDestroy {
constructor(
private searchBarService: SearchBarService,
private stateService: StateService,
private accountService: AccountService,
) {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.searchBarService.state$.subscribe((state) => {
@@ -33,7 +33,7 @@ export class SearchComponent implements OnInit, OnDestroy {
ngOnInit() {
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
this.activeAccountSubscription = this.stateService.activeAccount$.subscribe((value) => {
this.activeAccountSubscription = this.accountService.activeAccount$.subscribe((_) => {
this.searchBarService.setSearchText("");
this.searchText.patchValue("");
});