mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-21 11:35:34 +01:00
[PM-194] Browser Account Switcher UI (#6772)
* Handle switch messaging TODO: handle loading state for account switcher * Async updates required for state * Fallback to email for current account avatar * Await un-awaited promises * Remove unnecessary Prune Prune was getting confused in browser and deleting memory in browser on account switch. This method isn't needed since logout already removes memory data, which is the condition for pruning * Fix temp password in browser * Use direct memory access until data is serializable Safari uses a different message object extraction than firefox/chrome and is removing `UInt8Array`s. Until all data passed into StorageService is guaranteed serializable, we need to use direct access in state service * Reload badge and context menu on switch * Gracefully switch account as they log out. * Maintain location on account switch * Remove unused state definitions * Prefer null for state undefined can be misinterpreted to indicate a value has not been set. * Hack: structured clone in memory storage We are currently getting dead objects on account switch due to updating the object in the foreground state service. However, the storage service is owned by the background. This structured clone hack ensures that all objects stored in memory are owned by the appropriate context * Null check nullable values active account can be null, so we should include null safety in the equality * Correct background->foreground switch command * Already providing background memory storage * Handle connection and clipboard on switch account * Prefer strict equal * Ensure structuredClone is available to jsdom This is a deficiency in jsdom -- https://github.com/jsdom/jsdom/issues/3363 -- structured clone is well supported. * Fixup types in faker class * add avatar and simple navigation to header * add options buttons * add app-header to necessary pages * add back button and adjust avatar sizes * add helper text when account limit reached * convert magic number to constant * add clarifying comment * adjust homepage header styles * navigate to previousp page upon avatar click when already on '/account-switcher' * move account UI to own component * add i18n * show correct auth status * add aria-hidden to icons * use listbox role * add screen reader accessibility to account component * more SR a11y updates to account component * add hover and focus states to avatar * refactor hover and focus states for avatar * add screen reader text for avatar * add slide-down animation on account switcher close * remove comment * setup account component story * add all stories * move navigation call to account component * implement account lock * add button hover effect * implement account logout * implement lockAll accounts functionality * replace 'any' with custom type * add account switcher button to /home login page * use <main> tag (enables scrolling) * change temp server filler name * temporarily remove server arg from account story * don't show avatar on /home if no accounts, and don't show 'lock'/'logout' buttons if no currentAccount * add translation and a11y to /home avatar * add correct server url to account component * add 'server' to AccountOption type * Enabled account switching client-side feature flag. * add slide-in transition to /account-switcher page * change capitalization to reflect figma design * make screen reader read active account email, address more capitalization * fix web avatar misalignment * make avatar color based on user settings and in sync with other clients * make property private * change accountOptions to availableAccounts for clarity * refactor to remove 'else' template ref * remove empty scss rule * use tailwind instead of scss * rename isSelected to isActive * add 'isButton' to /home page avatar * move files to services folder * update import * Remove duplicate active account button * Move no account button to current-account component * Always complete logging out Fixes PM-4866 * make screenreader read off email, not name * refactor avatar for button case * Do not next object updates StateService's init was calling `updateState` at multiple layers, once overall and then again for each account updated. Because we were not maintaining a single state object through the process, it was ending up in a consistent, but incomplete state. Fixed by returning the updated state everywhere. This very well may not be all the bugs associated with this * Treat null switch account as no active user * Listen for switchAccountFinish before routing (#6950) * adjust avatar style when wrapped in a button * show alt text for favicon setting * move stories to browser * Send Finish Message on null * Dynamically set active user when locking all This is required because some user lock states are not recoverable after process reload (those with logout timeout). This waits until reload is occurring, then sets the next user appropriately * Move Finished Message to Finally Block Fix tests * Drop problematic key migration Fixes PM-4933. This was an instance of foreground/background collision when writing state. We have several other fallbacks of clearing these deprecated keys. * Prefer location to homebrew router service * Initialize account disk cache from background Uses the `isRecoveredSession` bool to re-initialize foreground caches from a background message. This avoids a lengthy first-read for foregrounds * PM-4865 - Browser Acct Switcher - only show lock btn for lockable accounts (#6991) * Lock of active account first, when locking multiple. Fixes PM-4996 * Fix linter * Hide lock now for locked users (#7020) * Hide lock now for locked users * Prefer disabling button to removing * Add tooltip to TDE without unlock method * Load all auth states on state init (#7027) This is a temporary fix until the owning services can update state themselves. It uses the presence of an auto key to surmise unlocked state on init. This is safe since it's run only once on extension start. * Ps/pm 5004/add load to account switcher (#7032) * Add load spinner to account switcher * Remove ul list icons * Properly size account switcher in popout * [PM-5005] Prevent Double Navigation (#7035) * Delete Overriden Method * Add Lock Transition * truncate email and server name * remove account.stories.ts (will add in separate PR) * Do not switch user at reload if no user is active * fix prettier issues --------- Co-authored-by: Matt Gibson <mgibson@bitwarden.com> Co-authored-by: Todd Martin <tmartin@bitwarden.com> Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Co-authored-by: bnagawiecki <107435978+bnagawiecki@users.noreply.github.com>
This commit is contained in:
parent
00faefa1d1
commit
ac899bebeb
@ -7,6 +7,7 @@
|
||||
},
|
||||
"flags": {
|
||||
"showPasswordless": true,
|
||||
"enableCipherKeyEncryption": false
|
||||
"enableCipherKeyEncryption": false,
|
||||
"accountSwitching": true
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
{
|
||||
"flags": {
|
||||
"enableCipherKeyEncryption": false
|
||||
"enableCipherKeyEncryption": false,
|
||||
"accountSwitching": true
|
||||
}
|
||||
}
|
||||
|
@ -365,6 +365,9 @@
|
||||
"unlockMethodNeededToChangeTimeoutActionDesc": {
|
||||
"message": "Set up an unlock method to change your vault timeout action."
|
||||
},
|
||||
"unlockMethodNeeded": {
|
||||
"message": "Set up an unlock method in Settings"
|
||||
},
|
||||
"rateExtension": {
|
||||
"message": "Rate the extension"
|
||||
},
|
||||
@ -405,6 +408,9 @@
|
||||
"lockNow": {
|
||||
"message": "Lock now"
|
||||
},
|
||||
"lockAll": {
|
||||
"message": "Lock all"
|
||||
},
|
||||
"immediately": {
|
||||
"message": "Immediately"
|
||||
},
|
||||
@ -1131,6 +1137,9 @@
|
||||
"faviconDesc": {
|
||||
"message": "Show a recognizable image next to each login."
|
||||
},
|
||||
"faviconDescAlt": {
|
||||
"message": "Show a recognizable image next to each login. Applies to all logged in accounts."
|
||||
},
|
||||
"enableBadgeCounter": {
|
||||
"message": "Show badge counter"
|
||||
},
|
||||
@ -2796,5 +2805,35 @@
|
||||
},
|
||||
"lastPassYubikeyDesc": {
|
||||
"message": "Insert the YubiKey associated with your LastPass account into your computer's USB port, then touch its button."
|
||||
},
|
||||
"switchAccount": {
|
||||
"message": "Switch account"
|
||||
},
|
||||
"switchAccounts": {
|
||||
"message": "Switch accounts"
|
||||
},
|
||||
"switchToAccount": {
|
||||
"message": "Switch to account"
|
||||
},
|
||||
"activeAccount": {
|
||||
"message": "Active account"
|
||||
},
|
||||
"accountLimitReached": {
|
||||
"message": "Account limit reached. Log out of an account to add another."
|
||||
},
|
||||
"active": {
|
||||
"message": "active"
|
||||
},
|
||||
"locked": {
|
||||
"message": "locked"
|
||||
},
|
||||
"unlocked": {
|
||||
"message": "unlocked"
|
||||
},
|
||||
"server": {
|
||||
"message": "server"
|
||||
},
|
||||
"hostedAt": {
|
||||
"message": "hosted at"
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,70 @@
|
||||
<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>
|
||||
<app-header>
|
||||
<div class="left">
|
||||
<button type="button" (click)="back()">{{ "close" | i18n }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="center tw-font-bold">{{ "switchAccounts" | i18n }}</div>
|
||||
</app-header>
|
||||
|
||||
<main
|
||||
*ngIf="loading"
|
||||
class="tw-absolute tw-z-50 tw-box-border tw-flex tw-cursor-not-allowed tw-items-center tw-justify-center tw-bg-background tw-opacity-60"
|
||||
>
|
||||
<i class="bwi bwi-spinner bwi-2x bwi-spin" aria-hidden="true"></i>
|
||||
</main>
|
||||
<main>
|
||||
<div class="tw-p-2">
|
||||
<div *ngIf="availableAccounts$ | async as availableAccounts">
|
||||
<ul class="tw-grid tw-list-none tw-gap-2" role="listbox">
|
||||
<li *ngFor="let availableAccount of availableAccounts" role="option">
|
||||
<auth-account [account]="availableAccount" (loading)="loading = $event"></auth-account>
|
||||
</li>
|
||||
</ul>
|
||||
<!--
|
||||
If the user has not reached the account limit, the last 'availableAccount' will have an 'id' of
|
||||
'SPECIAL_ADD_ACCOUNT_ID'. Since we don't want to count this as one of the actual accounts,
|
||||
we check to make sure the 'id' of the last 'availableAccount' is not equal to 'SPECIAL_ADD_ACCOUNT_ID'
|
||||
-->
|
||||
<p
|
||||
class="tw-text-sm tw-text-muted"
|
||||
*ngIf="
|
||||
availableAccounts.length >= accountLimit &&
|
||||
availableAccounts[availableAccounts.length - 1].id !== specialAddAccountId
|
||||
"
|
||||
>
|
||||
{{ "accountLimitReached" | i18n }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="tw-mt-8" *ngIf="currentAccount$ | async as currentAccount">
|
||||
<div class="tw-mb-2 tw-uppercase tw-text-muted">{{ "options" | i18n }}</div>
|
||||
<div class="tw-grid tw-gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-bg-background tw-p-3 hover:tw-bg-background-alt disabled:tw-cursor-not-allowed disabled:tw-border-text-muted/60 disabled:!tw-text-muted/60"
|
||||
(click)="lock()"
|
||||
[disabled]="currentAccount.status === lockedStatus || !activeUserCanLock"
|
||||
[title]="!activeUserCanLock ? ('unlockMethodNeeded' | i18n) : ''"
|
||||
>
|
||||
<i class="bwi bwi-lock tw-text-2xl" aria-hidden="true"></i>
|
||||
{{ "lockNow" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-bg-background tw-p-3 hover:tw-bg-background-alt"
|
||||
(click)="logOut()"
|
||||
>
|
||||
<i class="bwi bwi-sign-out tw-text-2xl" aria-hidden="true"></i>
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tw-mt-2 tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-bg-background tw-p-3 hover:tw-bg-background-alt"
|
||||
(click)="lockAll()"
|
||||
>
|
||||
<i class="bwi bwi-lock tw-text-2xl" aria-hidden="true"></i>
|
||||
{{ "lockAll" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
@ -1,25 +1,113 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { Location } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { Subject, firstValueFrom, map, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service";
|
||||
import { AccountSwitcherService } from "../services/account-switcher.service";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { AccountSwitcherService } from "./services/account-switcher.service";
|
||||
|
||||
@Component({
|
||||
templateUrl: "account-switcher.component.html",
|
||||
})
|
||||
export class AccountSwitcherComponent {
|
||||
export class AccountSwitcherComponent implements OnInit, OnDestroy {
|
||||
readonly lockedStatus = AuthenticationStatus.Locked;
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
loading = false;
|
||||
activeUserCanLock = false;
|
||||
|
||||
constructor(
|
||||
private accountSwitcherService: AccountSwitcherService,
|
||||
private accountService: AccountService,
|
||||
private vaultTimeoutService: VaultTimeoutService,
|
||||
private messagingService: MessagingService,
|
||||
private dialogService: DialogService,
|
||||
private location: Location,
|
||||
private router: Router,
|
||||
private routerService: BrowserRouterService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
) {}
|
||||
|
||||
get accountOptions$() {
|
||||
return this.accountSwitcherService.accountOptions$;
|
||||
get accountLimit() {
|
||||
return this.accountSwitcherService.ACCOUNT_LIMIT;
|
||||
}
|
||||
|
||||
async selectAccount(id: string) {
|
||||
await this.accountSwitcherService.selectAccount(id);
|
||||
this.router.navigate([this.routerService.getPreviousUrl() ?? "/home"]);
|
||||
get specialAddAccountId() {
|
||||
return this.accountSwitcherService.SPECIAL_ADD_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
get availableAccounts$() {
|
||||
return this.accountSwitcherService.availableAccounts$;
|
||||
}
|
||||
|
||||
get currentAccount$() {
|
||||
return this.accountService.activeAccount$;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
const availableVaultTimeoutActions = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
this.activeUserCanLock = availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock);
|
||||
}
|
||||
|
||||
back() {
|
||||
this.location.back();
|
||||
}
|
||||
|
||||
async lock(userId?: string) {
|
||||
this.loading = true;
|
||||
await this.vaultTimeoutService.lock(userId ? userId : null);
|
||||
this.router.navigate(["lock"]);
|
||||
}
|
||||
|
||||
async lockAll() {
|
||||
this.loading = true;
|
||||
this.availableAccounts$
|
||||
.pipe(
|
||||
map((accounts) =>
|
||||
accounts
|
||||
.filter((account) => account.id !== this.specialAddAccountId)
|
||||
.sort((a, b) => (a.isActive ? -1 : b.isActive ? 1 : 0)) // Log out of the active account first
|
||||
.map((account) => account.id),
|
||||
),
|
||||
switchMap(async (accountIds) => {
|
||||
if (accountIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Must lock active (first) account first, then order doesn't matter
|
||||
await this.vaultTimeoutService.lock(accountIds.shift());
|
||||
await Promise.all(accountIds.map((id) => this.vaultTimeoutService.lock(id)));
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe(() => this.router.navigate(["lock"]));
|
||||
}
|
||||
|
||||
async logOut() {
|
||||
this.loading = true;
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "logOut" },
|
||||
content: { key: "logOutConfirmation" },
|
||||
type: "info",
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
this.messagingService.send("logout");
|
||||
}
|
||||
|
||||
this.router.navigate(["account-switcher"]);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,49 @@
|
||||
<button
|
||||
*ngIf="account.id !== specialAccountAddId"
|
||||
type="button"
|
||||
class="tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-border-none tw-bg-background tw-p-3 hover:tw-bg-background-alt"
|
||||
(click)="selectAccount(account.id)"
|
||||
>
|
||||
<div class="tw-flex-shrink-0">
|
||||
<bit-avatar
|
||||
[id]="account.id"
|
||||
[text]="account.name"
|
||||
[color]="account.avatarColor"
|
||||
size="small"
|
||||
aria-hidden="true"
|
||||
></bit-avatar>
|
||||
</div>
|
||||
<div class="tw-text-left">
|
||||
<span class="tw-sr-only" *ngIf="status.text === 'active'"> {{ "activeAccount" | i18n }}: </span>
|
||||
<span class="tw-sr-only" *ngIf="status.text !== 'active'">
|
||||
{{ "switchToAccount" | i18n }}
|
||||
</span>
|
||||
<div class="tw-max-w-64 tw-truncate tw-text-main">
|
||||
{{ account.email }}
|
||||
</div>
|
||||
<div class="tw-max-w-64 tw-truncate tw-text-sm tw-text-muted">
|
||||
<span class="tw-sr-only">{{ "hostedAt" | i18n }}</span>
|
||||
{{ account.server }}
|
||||
</div>
|
||||
<div class="tw-text-sm tw-italic tw-text-muted" [attr.aria-hidden]="status.text === 'active'">
|
||||
<span class="tw-sr-only">(</span>
|
||||
{{ status.text }}
|
||||
<span class="tw-sr-only">)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-ml-auto tw-flex-shrink-0">
|
||||
<i class="bwi tw-text-2xl tw-text-main" [ngClass]="status.icon" aria-hidden="true"></i>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="account.id === specialAccountAddId"
|
||||
type="button"
|
||||
class="tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-border-none tw-bg-background tw-p-3 hover:tw-bg-background-alt"
|
||||
(click)="selectAccount(account.id)"
|
||||
>
|
||||
<i class="bwi bwi-plus tw-text-2xl tw-text-main" aria-hidden="true"></i>
|
||||
<div class="tw-text-main">
|
||||
{{ account.name }}
|
||||
</div>
|
||||
</button>
|
@ -0,0 +1,55 @@
|
||||
import { CommonModule, Location } from "@angular/common";
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { AvatarModule } from "@bitwarden/components";
|
||||
|
||||
import { AccountSwitcherService, AvailableAccount } from "./services/account-switcher.service";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "auth-account",
|
||||
templateUrl: "account.component.html",
|
||||
imports: [CommonModule, JslibModule, AvatarModule],
|
||||
})
|
||||
export class AccountComponent {
|
||||
@Input() account: AvailableAccount;
|
||||
@Output() loading = new EventEmitter<boolean>();
|
||||
|
||||
constructor(
|
||||
private accountSwitcherService: AccountSwitcherService,
|
||||
private router: Router,
|
||||
private location: Location,
|
||||
private i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
get specialAccountAddId() {
|
||||
return this.accountSwitcherService.SPECIAL_ADD_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
async selectAccount(id: string) {
|
||||
this.loading.emit(true);
|
||||
await this.accountSwitcherService.selectAccount(id);
|
||||
|
||||
if (id === this.specialAccountAddId) {
|
||||
this.router.navigate(["home"]);
|
||||
} else {
|
||||
this.location.back();
|
||||
}
|
||||
}
|
||||
|
||||
get status() {
|
||||
if (this.account.isActive && this.account.status !== AuthenticationStatus.Locked) {
|
||||
return { text: this.i18nService.t("active"), icon: "bwi-check-circle" };
|
||||
}
|
||||
|
||||
if (this.account.status === AuthenticationStatus.Unlocked) {
|
||||
return { text: this.i18nService.t("unlocked"), icon: "bwi-unlock" };
|
||||
}
|
||||
|
||||
return { text: this.i18nService.t("locked"), icon: "bwi-lock" };
|
||||
}
|
||||
}
|
@ -1,5 +1,35 @@
|
||||
<div *ngIf="currentAccount$ | async as currentAccount">
|
||||
<div (click)="currentAccountClicked()" class="tw-mr-1 tw-mt-1">
|
||||
<bit-avatar [id]="currentAccount.id" [text]="currentAccountName$ | async"></bit-avatar>
|
||||
</div>
|
||||
<div class="tw-mr-[5px] tw-mt-1">
|
||||
<button
|
||||
*ngIf="currentAccount$ | async as currentAccount; else defaultButton"
|
||||
type="button"
|
||||
class="tw-rounded-full hover:tw-outline hover:tw-outline-1 hover:tw-outline-offset-1"
|
||||
(click)="currentAccountClicked()"
|
||||
>
|
||||
<span class="tw-sr-only">{{ "switchAccounts" | i18n }}:</span>
|
||||
<span class="tw-sr-only"> {{ "activeAccount" | i18n }} {{ currentAccount.email }}</span>
|
||||
<bit-avatar
|
||||
[id]="currentAccount.id"
|
||||
[text]="currentAccount.name"
|
||||
[color]="currentAccount.avatarColor"
|
||||
size="small"
|
||||
aria-hidden="true"
|
||||
class="[&>img]:tw-block"
|
||||
></bit-avatar>
|
||||
</button>
|
||||
<ng-template #defaultButton>
|
||||
<button
|
||||
type="button"
|
||||
routerLink="/account-switcher"
|
||||
class="tw-rounded-full hover:tw-outline hover:tw-outline-1 hover:tw-outline-offset-1"
|
||||
>
|
||||
<span class="tw-sr-only">{{ "switchAccounts" | i18n }}</span>
|
||||
<bit-avatar
|
||||
[text]="'…'"
|
||||
[color]="'#175DDC'"
|
||||
size="small"
|
||||
aria-hidden="true"
|
||||
class="[&>img]:tw-block"
|
||||
></bit-avatar>
|
||||
</button>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { Location } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { map } from "rxjs";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CurrentAccountService } from "./services/current-account.service";
|
||||
|
||||
@Component({
|
||||
selector: "app-current-account",
|
||||
@ -11,23 +10,21 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
})
|
||||
export class CurrentAccountComponent {
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private currentAccountService: CurrentAccountService,
|
||||
private router: Router,
|
||||
private location: Location,
|
||||
private route: ActivatedRoute,
|
||||
) {}
|
||||
|
||||
get currentAccount$() {
|
||||
return this.accountService.activeAccount$;
|
||||
}
|
||||
|
||||
get currentAccountName$() {
|
||||
return this.currentAccount$.pipe(
|
||||
map((a) => {
|
||||
return Utils.isNullOrWhitespace(a.name) ? a.email : a.name;
|
||||
}),
|
||||
);
|
||||
return this.currentAccountService.currentAccount$;
|
||||
}
|
||||
|
||||
async currentAccountClicked() {
|
||||
await this.router.navigate(["/account-switcher"]);
|
||||
if (this.route.snapshot.data.state.includes("account-switcher")) {
|
||||
this.location.back();
|
||||
} else {
|
||||
this.router.navigate(["/account-switcher"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,8 @@ 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 { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.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";
|
||||
@ -16,6 +18,8 @@ describe("AccountSwitcherService", () => {
|
||||
const accountService = mock<AccountService>();
|
||||
const stateService = mock<StateService>();
|
||||
const messagingService = mock<MessagingService>();
|
||||
const environmentService = mock<EnvironmentService>();
|
||||
const logService = mock<LogService>();
|
||||
|
||||
let accountSwitcherService: AccountSwitcherService;
|
||||
|
||||
@ -27,10 +31,12 @@ describe("AccountSwitcherService", () => {
|
||||
accountService,
|
||||
stateService,
|
||||
messagingService,
|
||||
environmentService,
|
||||
logService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("accountOptions$", () => {
|
||||
describe("availableAccounts$", () => {
|
||||
it("should return all accounts and an add account option when accounts are less than 5", async () => {
|
||||
const user1AccountInfo: AccountInfo = {
|
||||
name: "Test User 1",
|
||||
@ -45,14 +51,14 @@ describe("AccountSwitcherService", () => {
|
||||
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "1" as UserId }));
|
||||
|
||||
const accounts = await firstValueFrom(
|
||||
accountSwitcherService.accountOptions$.pipe(timeout(20)),
|
||||
accountSwitcherService.availableAccounts$.pipe(timeout(20)),
|
||||
);
|
||||
expect(accounts).toHaveLength(2);
|
||||
expect(accounts[0].id).toBe("1");
|
||||
expect(accounts[0].isSelected).toBeTruthy();
|
||||
expect(accounts[0].isActive).toBeTruthy();
|
||||
|
||||
expect(accounts[1].id).toBe("addAccount");
|
||||
expect(accounts[1].isSelected).toBeFalsy();
|
||||
expect(accounts[1].isActive).toBeFalsy();
|
||||
});
|
||||
|
||||
it.each([5, 6])(
|
||||
@ -71,7 +77,7 @@ describe("AccountSwitcherService", () => {
|
||||
Object.assign(seedAccounts["1" as UserId], { id: "1" as UserId }),
|
||||
);
|
||||
|
||||
const accounts = await firstValueFrom(accountSwitcherService.accountOptions$);
|
||||
const accounts = await firstValueFrom(accountSwitcherService.availableAccounts$);
|
||||
|
||||
expect(accounts).toHaveLength(numberOfAccounts);
|
||||
accounts.forEach((account) => {
|
||||
@ -83,16 +89,46 @@ describe("AccountSwitcherService", () => {
|
||||
|
||||
describe("selectAccount", () => {
|
||||
it("initiates an add account logic when add account is selected", async () => {
|
||||
await accountSwitcherService.selectAccount("addAccount");
|
||||
let listener: (
|
||||
message: { command: string; userId: string },
|
||||
sender: unknown,
|
||||
sendResponse: unknown,
|
||||
) => void = null;
|
||||
jest.spyOn(chrome.runtime.onMessage, "addListener").mockImplementation((addedListener) => {
|
||||
listener = addedListener;
|
||||
});
|
||||
|
||||
expect(stateService.setActiveUser).toBeCalledWith(null);
|
||||
expect(stateService.setRememberedEmail).toBeCalledWith(null);
|
||||
const removeListenerSpy = jest.spyOn(chrome.runtime.onMessage, "removeListener");
|
||||
|
||||
expect(accountService.switchAccount).not.toBeCalled();
|
||||
const selectAccountPromise = accountSwitcherService.selectAccount("addAccount");
|
||||
|
||||
expect(listener).not.toBeNull();
|
||||
listener({ command: "switchAccountFinish", userId: null }, undefined, undefined);
|
||||
|
||||
await selectAccountPromise;
|
||||
|
||||
expect(accountService.switchAccount).toBeCalledWith(null);
|
||||
|
||||
expect(removeListenerSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it("initiates an account switch with an account id", async () => {
|
||||
await accountSwitcherService.selectAccount("1");
|
||||
let listener: (
|
||||
message: { command: string; userId: string },
|
||||
sender: unknown,
|
||||
sendResponse: unknown,
|
||||
) => void;
|
||||
jest.spyOn(chrome.runtime.onMessage, "addListener").mockImplementation((addedListener) => {
|
||||
listener = addedListener;
|
||||
});
|
||||
|
||||
const removeListenerSpy = jest.spyOn(chrome.runtime.onMessage, "removeListener");
|
||||
|
||||
const selectAccountPromise = accountSwitcherService.selectAccount("1");
|
||||
|
||||
listener({ command: "switchAccountFinish", userId: "1" }, undefined, undefined);
|
||||
|
||||
await selectAccountPromise;
|
||||
|
||||
expect(accountService.switchAccount).toBeCalledWith("1");
|
||||
expect(messagingService.send).toBeCalledWith(
|
||||
@ -101,6 +137,7 @@ describe("AccountSwitcherService", () => {
|
||||
return payload.userId === "1";
|
||||
}),
|
||||
);
|
||||
expect(removeListenerSpy).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,139 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import {
|
||||
Observable,
|
||||
combineLatest,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
switchMap,
|
||||
throwError,
|
||||
timeout,
|
||||
} from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.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";
|
||||
|
||||
import { fromChromeEvent } from "../../../../platform/browser/from-chrome-event";
|
||||
|
||||
export type AvailableAccount = {
|
||||
name: string;
|
||||
email?: string;
|
||||
id: string;
|
||||
isActive: boolean;
|
||||
server?: string;
|
||||
status?: AuthenticationStatus;
|
||||
avatarColor?: string;
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class AccountSwitcherService {
|
||||
static incompleteAccountSwitchError = "Account switch did not complete.";
|
||||
|
||||
ACCOUNT_LIMIT = 5;
|
||||
SPECIAL_ADD_ACCOUNT_ID = "addAccount";
|
||||
availableAccounts$: Observable<AvailableAccount[]>;
|
||||
|
||||
switchAccountFinished$: Observable<string>;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private stateService: StateService,
|
||||
private messagingService: MessagingService,
|
||||
private environmentService: EnvironmentService,
|
||||
private logService: LogService,
|
||||
) {
|
||||
this.availableAccounts$ = combineLatest([
|
||||
this.accountService.accounts$,
|
||||
this.accountService.activeAccount$,
|
||||
]).pipe(
|
||||
switchMap(async ([accounts, activeAccount]) => {
|
||||
const accountEntries = Object.entries(accounts).filter(
|
||||
([_, account]) => account.status !== AuthenticationStatus.LoggedOut,
|
||||
);
|
||||
// Accounts shouldn't ever be more than ACCOUNT_LIMIT but just in case do a greater than
|
||||
const hasMaxAccounts = accountEntries.length >= this.ACCOUNT_LIMIT;
|
||||
const options: AvailableAccount[] = await Promise.all(
|
||||
accountEntries.map(async ([id, account]) => {
|
||||
return {
|
||||
name: account.name ?? account.email,
|
||||
email: account.email,
|
||||
id: id,
|
||||
server: await this.environmentService.getHost(id),
|
||||
status: account.status,
|
||||
isActive: id === activeAccount?.id,
|
||||
avatarColor: await this.stateService.getAvatarColor({ userId: id }),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (!hasMaxAccounts) {
|
||||
options.push({
|
||||
name: "Add account",
|
||||
id: this.SPECIAL_ADD_ACCOUNT_ID,
|
||||
isActive: activeAccount?.id == null,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a reusable observable that listens to the the switchAccountFinish message and returns the userId from the message
|
||||
this.switchAccountFinished$ = fromChromeEvent<[message: { command: string; userId: string }]>(
|
||||
chrome.runtime.onMessage,
|
||||
).pipe(
|
||||
filter(([message]) => message.command === "switchAccountFinish"),
|
||||
map(([message]) => message.userId),
|
||||
);
|
||||
}
|
||||
|
||||
get specialAccountAddId() {
|
||||
return this.SPECIAL_ADD_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
async selectAccount(id: string) {
|
||||
if (id === this.SPECIAL_ADD_ACCOUNT_ID) {
|
||||
id = null;
|
||||
}
|
||||
|
||||
// Creates a subscription to the switchAccountFinished observable but further
|
||||
// filters it to only care about the current userId.
|
||||
const switchAccountFinishedPromise = firstValueFrom(
|
||||
this.switchAccountFinished$.pipe(
|
||||
filter((userId) => userId === id),
|
||||
timeout({
|
||||
// Much longer than account switching is expected to take for normal accounts
|
||||
// but the account switching process includes a possible full sync so we need to account
|
||||
// for very large accounts and want to still have a timeout
|
||||
// to avoid a promise that might never resolve/reject
|
||||
first: 60_000,
|
||||
with: () =>
|
||||
throwError(() => new Error(AccountSwitcherService.incompleteAccountSwitchError)),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Initiate the actions required to make account switching happen
|
||||
await this.accountService.switchAccount(id as UserId);
|
||||
this.messagingService.send("switchAccount", { userId: id }); // This message should cause switchAccountFinish to be sent
|
||||
|
||||
// Wait until we recieve the switchAccountFinished message
|
||||
await switchAccountFinishedPromise.catch((err) => {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message === AccountSwitcherService.incompleteAccountSwitchError
|
||||
) {
|
||||
this.logService.warning("message 'switchAccount' never responded.");
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Observable, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export type CurrentAccount = {
|
||||
id: UserId;
|
||||
name: string | undefined;
|
||||
email: string;
|
||||
status: AuthenticationStatus;
|
||||
avatarColor: string;
|
||||
};
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class CurrentAccountService {
|
||||
currentAccount$: Observable<CurrentAccount>;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private stateService: StateService,
|
||||
) {
|
||||
this.currentAccount$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap(async (account) => {
|
||||
if (account == null) {
|
||||
return null;
|
||||
}
|
||||
const currentAccount: CurrentAccount = {
|
||||
id: account.id,
|
||||
name: account.name || account.email,
|
||||
email: account.email,
|
||||
status: account.status,
|
||||
avatarColor: await this.stateService.getAvatarColor({ userId: account.id }),
|
||||
};
|
||||
|
||||
return currentAccount;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
<app-header [noTheme]="true"></app-header>
|
||||
<app-header [noTheme]="true"> </app-header>
|
||||
<div class="center-content">
|
||||
<div class="content login-page">
|
||||
<div class="logo-image"></div>
|
||||
|
@ -10,6 +10,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
|
||||
import { AccountSwitcherService } from "./account-switching/services/account-switcher.service";
|
||||
|
||||
@Component({
|
||||
selector: "app-home",
|
||||
templateUrl: "home.component.html",
|
||||
@ -33,6 +35,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
private i18nService: I18nService,
|
||||
private environmentService: EnvironmentService,
|
||||
private loginService: LoginService,
|
||||
private accountSwitcherService: AccountSwitcherService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@ -67,6 +70,10 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.destroyed$.complete();
|
||||
}
|
||||
|
||||
get availableAccounts$() {
|
||||
return this.accountSwitcherService.availableAccounts$;
|
||||
}
|
||||
|
||||
submit() {
|
||||
this.formGroup.markAllAsTouched();
|
||||
if (this.formGroup.invalid) {
|
||||
|
@ -1,60 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
await this.accountService.switchAccount(id as UserId);
|
||||
this.messagingService.send("switchAccount", { userId: id });
|
||||
}
|
||||
}
|
@ -625,6 +625,7 @@ export default class MainBackground {
|
||||
this.platformUtilsService,
|
||||
systemUtilsServiceReloadCallback,
|
||||
this.stateService,
|
||||
this.vaultTimeoutSettingsService,
|
||||
);
|
||||
|
||||
// Other fields
|
||||
@ -832,28 +833,39 @@ export default class MainBackground {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch accounts to indicated userId -- null is no active user
|
||||
*/
|
||||
async switchAccount(userId: UserId) {
|
||||
if (userId != null) {
|
||||
try {
|
||||
await this.stateService.setActiveUser(userId);
|
||||
}
|
||||
|
||||
const status = await this.authService.getAuthStatus(userId);
|
||||
const forcePasswordReset =
|
||||
(await this.stateService.getForceSetPasswordReason({ userId: userId })) !=
|
||||
ForceSetPasswordReason.None;
|
||||
if (userId == null) {
|
||||
await this.stateService.setRememberedEmail(null);
|
||||
await this.refreshBadge();
|
||||
await this.refreshMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.systemService.clearPendingClipboard();
|
||||
await this.notificationsService.updateConnection(false);
|
||||
const status = await this.authService.getAuthStatus(userId);
|
||||
const forcePasswordReset =
|
||||
(await this.stateService.getForceSetPasswordReason({ userId: userId })) !=
|
||||
ForceSetPasswordReason.None;
|
||||
|
||||
if (status === AuthenticationStatus.Locked) {
|
||||
this.messagingService.send("locked", { userId: userId });
|
||||
} else if (forcePasswordReset) {
|
||||
this.messagingService.send("update-temp-password", { userId: userId });
|
||||
} else {
|
||||
this.messagingService.send("unlocked", { userId: userId });
|
||||
await this.refreshBadge();
|
||||
await this.refreshMenu();
|
||||
await this.syncService.fullSync(false);
|
||||
await this.systemService.clearPendingClipboard();
|
||||
await this.notificationsService.updateConnection(false);
|
||||
|
||||
if (status === AuthenticationStatus.Locked) {
|
||||
this.messagingService.send("locked", { userId: userId });
|
||||
} else if (forcePasswordReset) {
|
||||
this.messagingService.send("update-temp-password", { userId: userId });
|
||||
} else {
|
||||
this.messagingService.send("unlocked", { userId: userId });
|
||||
await this.refreshBadge();
|
||||
await this.refreshMenu();
|
||||
await this.syncService.fullSync(false);
|
||||
}
|
||||
} finally {
|
||||
this.messagingService.send("switchAccountFinish", { userId: userId });
|
||||
}
|
||||
}
|
||||
@ -882,8 +894,9 @@ export default class MainBackground {
|
||||
|
||||
if (newActiveUser != null) {
|
||||
// we have a new active user, do not continue tearing down application
|
||||
this.switchAccount(newActiveUser as UserId);
|
||||
await this.switchAccount(newActiveUser as UserId);
|
||||
this.messagingService.send("switchAccountFinish");
|
||||
this.messagingService.send("doneLoggingOut", { expired: expired, userId: userId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -315,6 +315,11 @@ export class BrowserApi {
|
||||
return chrome.runtime.sendMessage(message);
|
||||
}
|
||||
|
||||
static sendMessageWithResponse<TResponse>(subscriber: string, arg: any = {}) {
|
||||
const message = Object.assign({}, { command: subscriber }, arg);
|
||||
return new Promise<TResponse>((resolve) => chrome.runtime.sendMessage(message, resolve));
|
||||
}
|
||||
|
||||
static async focusTab(tabId: number) {
|
||||
chrome.tabs.update(tabId, { active: true, highlighted: true });
|
||||
}
|
||||
|
@ -69,9 +69,31 @@ export class BrowserStateService
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
BrowserApi.addListener(
|
||||
chrome.runtime.onMessage,
|
||||
(message: { command: string }, _, respond) => {
|
||||
if (message.command === "initializeDiskCache") {
|
||||
respond(JSON.stringify(this.accountDiskCache.value));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
override async initAccountState(): Promise<void> {
|
||||
if (this.isRecoveredSession && this.useAccountCache) {
|
||||
// request cache initialization
|
||||
|
||||
const response = await BrowserApi.sendMessageWithResponse<string>("initializeDiskCache");
|
||||
this.accountDiskCache.next(JSON.parse(response));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await super.initAccountState();
|
||||
}
|
||||
|
||||
async addAccount(account: Account) {
|
||||
// Apply browser overrides to default account values
|
||||
account = new Account(account);
|
||||
|
@ -214,4 +214,9 @@ export const routerTransition = trigger("routerTransition", [
|
||||
|
||||
transition("tabs => autofill", inSlideLeft),
|
||||
transition("autofill => tabs", outSlideRight),
|
||||
|
||||
transition("* => account-switcher", inSlideUp),
|
||||
transition("account-switcher => *", outSlideDown),
|
||||
|
||||
transition("lock => *", outSlideDown),
|
||||
]);
|
||||
|
@ -93,9 +93,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.activeUserId === null) {
|
||||
this.router.navigate(["home"]);
|
||||
}
|
||||
this.router.navigate(["home"]);
|
||||
});
|
||||
this.changeDetectorRef.detectChanges();
|
||||
} else if (msg.command === "authBlocked") {
|
||||
|
@ -18,6 +18,7 @@ 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 { AccountComponent } from "../auth/popup/account-switching/account.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";
|
||||
@ -105,6 +106,7 @@ import "../platform/popup/locales";
|
||||
DialogModule,
|
||||
FilePopoutCalloutComponent,
|
||||
AvatarModule,
|
||||
AccountComponent,
|
||||
],
|
||||
declarations: [
|
||||
ActionButtonsComponent,
|
||||
|
@ -176,11 +176,10 @@ cdk-virtual-scroll-viewport::-webkit-scrollbar-thumb,
|
||||
}
|
||||
|
||||
header:not(bit-callout header) {
|
||||
max-height: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
|
||||
&:not(.no-theme) {
|
||||
min-height: 44px;
|
||||
border-bottom: 1px solid #000000;
|
||||
|
||||
@include themify($themes) {
|
||||
@ -231,7 +230,7 @@ header:not(bit-callout header) {
|
||||
}
|
||||
|
||||
app-pop-out > button,
|
||||
div > button,
|
||||
div > button:not(app-current-account button):not(.home-acc-switcher-btn),
|
||||
div > a {
|
||||
border: none;
|
||||
padding: 0 10px;
|
||||
@ -243,8 +242,8 @@ header:not(bit-callout header) {
|
||||
height: 100%;
|
||||
white-space: pre;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
&:not(.home-acc-switcher-btn):hover,
|
||||
&:not(.home-acc-switcher-btn):focus {
|
||||
@include themify($themes) {
|
||||
background-color: themed("headerBackgroundHoverColor");
|
||||
color: themed("headerColor");
|
||||
|
@ -205,7 +205,9 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div id="faviconHelp" class="box-footer">{{ "faviconDesc" | i18n }}</div>
|
||||
<div id="faviconHelp" class="box-footer">
|
||||
{{ accountSwitcherEnabled ? ("faviconDescAlt" | i18n) : ("faviconDesc" | i18n) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<div class="box-content">
|
||||
|
@ -1,4 +1,4 @@
|
||||
<header>
|
||||
<app-header>
|
||||
<div class="left">
|
||||
<app-pop-out></app-pop-out>
|
||||
</div>
|
||||
@ -6,7 +6,7 @@
|
||||
<span class="title">{{ "settings" | i18n }}</span>
|
||||
</h1>
|
||||
<div class="right"></div>
|
||||
</header>
|
||||
</app-header>
|
||||
<main tabindex="-1" [formGroup]="form">
|
||||
<div class="box list">
|
||||
<h2 class="box-header">{{ "manage" | i18n }}</h2>
|
||||
|
@ -1,4 +1,4 @@
|
||||
<header>
|
||||
<app-header>
|
||||
<div class="left">
|
||||
<app-pop-out [show]="!comingFromAddEdit"></app-pop-out>
|
||||
<button type="button" (click)="close()" *ngIf="comingFromAddEdit">
|
||||
@ -13,7 +13,7 @@
|
||||
{{ "select" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</app-header>
|
||||
<main tabindex="-1">
|
||||
<app-callout type="info" *ngIf="enforcedPasswordPolicyOptions?.inEffect() && type === 'password'">
|
||||
{{ "passwordGeneratorPolicyInEffect" | i18n }}
|
||||
|
@ -1,9 +1,9 @@
|
||||
<header>
|
||||
<app-header>
|
||||
<div class="left" *ngIf="showLeftHeader">
|
||||
<app-pop-out></app-pop-out>
|
||||
</div>
|
||||
<h1 class="sr-only">{{ "send" | i18n }}</h1>
|
||||
<div class="search">
|
||||
<div class="search center">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="{{ 'searchSends' | i18n }}"
|
||||
@ -25,7 +25,7 @@
|
||||
<i class="bwi bwi-plus bwi-lg bwi-fw" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</app-header>
|
||||
<main tabindex="-1" [ngClass]="{ flex: disableSend, 'tab-page': disableSend }">
|
||||
<app-callout type="warning" title="{{ 'sendDisabled' | i18n }}" *ngIf="disableSend">
|
||||
{{ "sendDisabledWarning" | i18n }}
|
||||
|
@ -1,4 +1,4 @@
|
||||
<header>
|
||||
<app-header>
|
||||
<h1 class="sr-only">{{ "currentTab" | i18n }}</h1>
|
||||
<div class="left">
|
||||
<app-pop-out *ngIf="!inSidebar"></app-pop-out>
|
||||
@ -11,7 +11,7 @@
|
||||
<i class="bwi bwi-refresh-tab bwi-lg bwi-fw" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="search">
|
||||
<div class="search center">
|
||||
<input
|
||||
type="{{ searchTypeSearch ? 'search' : 'text' }}"
|
||||
placeholder="{{ 'searchVault' | i18n }}"
|
||||
@ -29,7 +29,7 @@
|
||||
<i class="bwi bwi-plus bwi-lg bwi-fw" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</app-header>
|
||||
<main tabindex="-1">
|
||||
<div class="no-items" *ngIf="!loaded">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
OBSERVABLE_DISK_STORAGE,
|
||||
} from "@bitwarden/angular/services/injection-tokens";
|
||||
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
@ -114,6 +115,7 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK");
|
||||
PlatformUtilsServiceAbstraction,
|
||||
RELOAD_CALLBACK,
|
||||
StateServiceAbstraction,
|
||||
VaultTimeoutSettingsService,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
@ -3,7 +3,6 @@ import { Directive, OnInit } from "@angular/core";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums/key-suffix-options.enum";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { ModalRef } from "../../components/modal/modal.ref";
|
||||
@ -52,7 +51,6 @@ export class SetPinComponent implements OnInit {
|
||||
} else {
|
||||
await this.stateService.setPinKeyEncryptedUserKey(pinProtectedKey);
|
||||
}
|
||||
await this.cryptoService.clearDeprecatedKeys(KeySuffixOptions.Pin);
|
||||
|
||||
this.modalRef.close(true);
|
||||
}
|
||||
|
@ -547,4 +547,5 @@ export abstract class StateService<T extends Account = Account> {
|
||||
* @param options Defines the storage options for the URL; Defaults to session Storage.
|
||||
*/
|
||||
setDeepLinkRedirectUrl: (url: string, options?: StorageOptions) => Promise<void>;
|
||||
nextUpActiveUser: () => Promise<UserId>;
|
||||
}
|
||||
|
@ -96,7 +96,7 @@ export class StateService<
|
||||
activeAccountUnlocked$ = this.activeAccountUnlockedSubject.asObservable();
|
||||
|
||||
private hasBeenInited = false;
|
||||
private isRecoveredSession = false;
|
||||
protected isRecoveredSession = false;
|
||||
|
||||
protected accountDiskCache = new BehaviorSubject<Record<string, TAccount>>({});
|
||||
|
||||
@ -159,7 +159,7 @@ export class StateService<
|
||||
(await this.storageService.get<string[]>(keys.authenticatedAccounts)) ?? [];
|
||||
for (const i in state.authenticatedAccounts) {
|
||||
if (i != null) {
|
||||
await this.syncAccountFromDisk(state.authenticatedAccounts[i]);
|
||||
state = await this.syncAccountFromDisk(state.authenticatedAccounts[i]);
|
||||
}
|
||||
}
|
||||
const storedActiveUser = await this.storageService.get<string>(keys.activeUserId);
|
||||
@ -186,25 +186,37 @@ export class StateService<
|
||||
});
|
||||
}
|
||||
|
||||
async syncAccountFromDisk(userId: string) {
|
||||
async syncAccountFromDisk(userId: string): Promise<State<TGlobalState, TAccount>> {
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
await this.updateState(async (state) => {
|
||||
const diskAccount = await this.getAccountFromDisk({ userId: userId });
|
||||
const state = await this.updateState(async (state) => {
|
||||
if (state.accounts == null) {
|
||||
state.accounts = {};
|
||||
}
|
||||
state.accounts[userId] = this.createAccount();
|
||||
const diskAccount = await this.getAccountFromDisk({ userId: userId });
|
||||
state.accounts[userId].profile = diskAccount.profile;
|
||||
// TODO: Temporary update to avoid routing all account status changes through account service for now.
|
||||
await this.accountService.addAccount(userId as UserId, {
|
||||
status: AuthenticationStatus.Locked,
|
||||
name: diskAccount.profile.name,
|
||||
email: diskAccount.profile.email,
|
||||
});
|
||||
return state;
|
||||
});
|
||||
|
||||
// TODO: Temporary update to avoid routing all account status changes through account service for now.
|
||||
// The determination of state should be handled by the various services that control those values.
|
||||
const token = await this.getAccessToken({ userId: userId });
|
||||
const autoKey = await this.getUserKeyAutoUnlock({ userId: userId });
|
||||
const accountStatus =
|
||||
token == null
|
||||
? AuthenticationStatus.LoggedOut
|
||||
: autoKey == null
|
||||
? AuthenticationStatus.Locked
|
||||
: AuthenticationStatus.Unlocked;
|
||||
await this.accountService.addAccount(userId as UserId, {
|
||||
status: accountStatus,
|
||||
name: diskAccount.profile.name,
|
||||
email: diskAccount.profile.email,
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async addAccount(account: TAccount) {
|
||||
@ -3033,7 +3045,7 @@ export class StateService<
|
||||
}
|
||||
|
||||
protected async saveAccountToMemory(account: TAccount): Promise<void> {
|
||||
if (this.getAccountFromMemory({ userId: account.profile.userId }) !== null) {
|
||||
if ((await this.getAccountFromMemory({ userId: account.profile.userId })) !== null) {
|
||||
await this.updateState((state) => {
|
||||
return new Promise((resolve) => {
|
||||
state.accounts[account.profile.userId] = account;
|
||||
@ -3320,10 +3332,9 @@ export class StateService<
|
||||
await this.removeAccountFromSecureStorage(userId);
|
||||
}
|
||||
|
||||
protected async dynamicallySetActiveUser() {
|
||||
async nextUpActiveUser() {
|
||||
const accounts = (await this.state())?.accounts;
|
||||
if (accounts == null || Object.keys(accounts).length < 1) {
|
||||
await this.setActiveUser(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -3338,6 +3349,11 @@ export class StateService<
|
||||
}
|
||||
newActiveUser = null;
|
||||
}
|
||||
return newActiveUser as UserId;
|
||||
}
|
||||
|
||||
protected async dynamicallySetActiveUser() {
|
||||
const newActiveUser = await this.nextUpActiveUser();
|
||||
await this.setActiveUser(newActiveUser);
|
||||
return newActiveUser;
|
||||
}
|
||||
@ -3369,20 +3385,23 @@ export class StateService<
|
||||
return state;
|
||||
}
|
||||
|
||||
private async setState(state: State<TGlobalState, TAccount>): Promise<void> {
|
||||
private async setState(
|
||||
state: State<TGlobalState, TAccount>,
|
||||
): Promise<State<TGlobalState, TAccount>> {
|
||||
await this.memoryStorageService.save(keys.state, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
protected async updateState(
|
||||
stateUpdater: (state: State<TGlobalState, TAccount>) => Promise<State<TGlobalState, TAccount>>,
|
||||
) {
|
||||
await this.state().then(async (state) => {
|
||||
): Promise<State<TGlobalState, TAccount>> {
|
||||
return await this.state().then(async (state) => {
|
||||
const updatedState = await stateUpdater(state);
|
||||
if (updatedState == null) {
|
||||
throw new Error("Attempted to update state to null value");
|
||||
}
|
||||
|
||||
await this.setState(updatedState);
|
||||
return await this.setState(updatedState);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, timeout } from "rxjs";
|
||||
|
||||
import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { AuthService } from "../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { MessagingService } from "../abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
@ -18,6 +20,7 @@ export class SystemService implements SystemServiceAbstraction {
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private reloadCallback: () => Promise<void> = null,
|
||||
private stateService: StateService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
) {}
|
||||
|
||||
async startProcessReload(authService: AuthService): Promise<void> {
|
||||
@ -54,6 +57,19 @@ export class SystemService implements SystemServiceAbstraction {
|
||||
if (!biometricLockedFingerprintValidated) {
|
||||
clearInterval(this.reloadInterval);
|
||||
this.reloadInterval = null;
|
||||
|
||||
const currentUser = await firstValueFrom(this.stateService.activeAccount$.pipe(timeout(500)));
|
||||
// Replace current active user if they will be logged out on reload
|
||||
if (currentUser != null) {
|
||||
const timeoutAction = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.vaultTimeoutAction$().pipe(timeout(500)),
|
||||
);
|
||||
if (timeoutAction === VaultTimeoutAction.LogOut) {
|
||||
const nextUser = await this.stateService.nextUpActiveUser();
|
||||
await this.stateService.setActiveUser(nextUser);
|
||||
}
|
||||
}
|
||||
|
||||
this.messagingService.send("reloadProcess");
|
||||
if (this.reloadCallback != null) {
|
||||
await this.reloadCallback();
|
||||
|
@ -3,6 +3,7 @@ const config = require("./tailwind.config.base");
|
||||
|
||||
config.content = [
|
||||
"libs/components/src/**/*.{html,ts,mdx}",
|
||||
"libs/auth/src/**/*.{html,ts,mdx}",
|
||||
"apps/web/src/**/*.{html,ts,mdx}",
|
||||
"bitwarden_license/bit-web/src/**/*.{html,ts,mdx}",
|
||||
".storybook/preview.tsx",
|
||||
|
Loading…
Reference in New Issue
Block a user