[PM-7972] Account switching integration with "remember email" functionality (#9750)

* add account switching logic to login email service

* enforce boolean and fix desktop account switcher order
This commit is contained in:
Jake Fink 2024-07-03 09:53:40 -04:00 committed by GitHub
parent 5839999fc4
commit 052b3be2eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 225 additions and 32 deletions

View File

@ -8,7 +8,6 @@ import {
AuthRequestServiceAbstraction, AuthRequestServiceAbstraction,
AuthRequestService, AuthRequestService,
LoginEmailServiceAbstraction, LoginEmailServiceAbstraction,
LoginEmailService,
LogoutReason, LogoutReason,
} from "@bitwarden/auth/common"; } from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
@ -708,8 +707,6 @@ export default class MainBackground {
this.stateProvider, this.stateProvider,
); );
this.loginEmailService = new LoginEmailService(this.stateProvider);
this.ssoLoginService = new SsoLoginService(this.stateProvider); this.ssoLoginService = new SsoLoginService(this.stateProvider);
this.userVerificationApiService = new UserVerificationApiService(this.apiService); this.userVerificationApiService = new UserVerificationApiService(this.apiService);
@ -1255,9 +1252,6 @@ export default class MainBackground {
clearCaches(); clearCaches();
if (userId == null) { if (userId == null) {
this.loginEmailService.setRememberEmail(false);
await this.loginEmailService.saveEmailSettings();
await this.refreshBadge(); await this.refreshBadge();
await this.refreshMenu(); await this.refreshMenu();
await this.overlayBackground?.updateOverlayCiphers(); // null in popup only contexts await this.overlayBackground?.updateOverlayCiphers(); // null in popup only contexts

View File

@ -165,13 +165,10 @@ export class AccountSwitcherComponent {
async addAccount() { async addAccount() {
this.close(); this.close();
this.loginEmailService.setRememberEmail(false);
await this.loginEmailService.saveEmailSettings();
await this.router.navigate(["/login"]);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$); const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
await this.stateService.clearDecryptedData(activeAccount?.id as UserId); await this.stateService.clearDecryptedData(activeAccount?.id as UserId);
await this.accountService.switchAccount(null); await this.accountService.switchAccount(null);
await this.router.navigate(["/login"]);
} }
private async createInactiveAccounts(baseAccounts: { private async createInactiveAccounts(baseAccounts: {

View File

@ -126,7 +126,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
let rememberEmail = this.loginEmailService.getRememberEmail(); let rememberEmail = this.loginEmailService.getRememberEmail();
if (rememberEmail == null) { if (!rememberEmail) {
rememberEmail = (await firstValueFrom(this.loginEmailService.storedEmail$)) != null; rememberEmail = (await firstValueFrom(this.loginEmailService.storedEmail$)) != null;
} }

View File

@ -970,7 +970,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({ safeProvider({
provide: LoginEmailServiceAbstraction, provide: LoginEmailServiceAbstraction,
useClass: LoginEmailService, useClass: LoginEmailService,
deps: [StateProvider], deps: [AccountServiceAbstraction, AuthServiceAbstraction, StateProvider],
}), }),
safeProvider({ safeProvider({
provide: OrgDomainInternalServiceAbstraction, provide: OrgDomainInternalServiceAbstraction,

View File

@ -2,36 +2,40 @@ import { Observable } from "rxjs";
export abstract class LoginEmailServiceAbstraction { export abstract class LoginEmailServiceAbstraction {
/** /**
* An observable that monitors the storedEmail * An observable that monitors the storedEmail on disk.
* This will return null if an account is being added.
*/ */
storedEmail$: Observable<string>; storedEmail$: Observable<string | null>;
/** /**
* Gets the current email being used in the login process. * Gets the current email being used in the login process from memory.
* @returns A string of the email. * @returns A string of the email.
*/ */
getEmail: () => string; getEmail: () => string;
/** /**
* Sets the current email being used in the login process. * Sets the current email being used in the login process in memory.
* @param email The email to be set. * @param email The email to be set.
*/ */
setEmail: (email: string) => void; setEmail: (email: string) => void;
/** /**
* Gets whether or not the email should be stored on disk. * Gets from memory whether or not the email should be stored on disk when `saveEmailSettings` is called.
* @returns A boolean stating whether or not the email should be stored on disk. * @returns A boolean stating whether or not the email should be stored on disk.
*/ */
getRememberEmail: () => boolean; getRememberEmail: () => boolean;
/** /**
* Sets whether or not the email should be stored on disk. * Sets in memory whether or not the email should be stored on disk when
* `saveEmailSettings` is called.
*/ */
setRememberEmail: (value: boolean) => void; setRememberEmail: (value: boolean) => void;
/** /**
* Sets the email and rememberEmail properties to null. * Sets the email and rememberEmail properties in memory to null.
*/ */
clearValues: () => void; clearValues: () => void;
/** /**
* - If rememberEmail is true, sets the storedEmail on disk to the current email. * Saves or clears the email on disk
* - If rememberEmail is false, sets the storedEmail on disk to null. * - If an account is being added, only changes the stored email when rememberEmail is true.
* - Then sets the email and rememberEmail properties to null. * - If rememberEmail is true, sets the email on disk to the current email.
* - If rememberEmail is false, sets the email on disk to null.
* Always clears the email and rememberEmail properties from memory.
* @returns A promise that resolves once the email settings are saved. * @returns A promise that resolves once the email settings are saved.
*/ */
saveEmailSettings: () => Promise<void>; saveEmailSettings: () => Promise<void>;

View File

@ -0,0 +1,150 @@
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import {
FakeAccountService,
FakeGlobalState,
FakeStateProvider,
mockAccountServiceWith,
} from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { LoginEmailService, STORED_EMAIL } from "./login-email.service";
describe("LoginEmailService", () => {
let sut: LoginEmailService;
let accountService: FakeAccountService;
let authService: MockProxy<AuthService>;
let stateProvider: FakeStateProvider;
const userId = "USER_ID" as UserId;
let storedEmailState: FakeGlobalState<string>;
let mockAuthStatuses$: BehaviorSubject<Record<UserId, AuthenticationStatus>>;
beforeEach(() => {
accountService = mockAccountServiceWith(userId);
authService = mock<AuthService>();
stateProvider = new FakeStateProvider(accountService);
storedEmailState = stateProvider.global.getFake(STORED_EMAIL);
mockAuthStatuses$ = new BehaviorSubject<Record<UserId, AuthenticationStatus>>({});
authService.authStatuses$ = mockAuthStatuses$;
sut = new LoginEmailService(accountService, authService, stateProvider);
});
afterEach(() => {
jest.clearAllMocks();
});
describe("storedEmail$", () => {
it("returns the stored email when not adding an account", async () => {
sut.setEmail("userEmail@bitwarden.com");
sut.setRememberEmail(true);
await sut.saveEmailSettings();
const result = await firstValueFrom(sut.storedEmail$);
expect(result).toEqual("userEmail@bitwarden.com");
});
it("returns the stored email when not adding an account and the user has just logged in", async () => {
sut.setEmail("userEmail@bitwarden.com");
sut.setRememberEmail(true);
await sut.saveEmailSettings();
mockAuthStatuses$.next({ [userId]: AuthenticationStatus.Unlocked });
// account service already initialized with userId as active user
const result = await firstValueFrom(sut.storedEmail$);
expect(result).toEqual("userEmail@bitwarden.com");
});
it("returns null when adding an account", async () => {
sut.setEmail("userEmail@bitwarden.com");
sut.setRememberEmail(true);
await sut.saveEmailSettings();
mockAuthStatuses$.next({
[userId]: AuthenticationStatus.Unlocked,
["OtherUserId" as UserId]: AuthenticationStatus.Locked,
});
const result = await firstValueFrom(sut.storedEmail$);
expect(result).toBeNull();
});
});
describe("saveEmailSettings", () => {
it("saves the email when not adding an account", async () => {
sut.setEmail("userEmail@bitwarden.com");
sut.setRememberEmail(true);
await sut.saveEmailSettings();
const result = await firstValueFrom(storedEmailState.state$);
expect(result).toEqual("userEmail@bitwarden.com");
});
it("clears the email when not adding an account and rememberEmail is false", async () => {
storedEmailState.stateSubject.next("initialEmail@bitwarden.com");
sut.setEmail("userEmail@bitwarden.com");
sut.setRememberEmail(false);
await sut.saveEmailSettings();
const result = await firstValueFrom(storedEmailState.state$);
expect(result).toBeNull();
});
it("saves the email when adding an account", async () => {
mockAuthStatuses$.next({
[userId]: AuthenticationStatus.Unlocked,
["OtherUserId" as UserId]: AuthenticationStatus.Locked,
});
sut.setEmail("userEmail@bitwarden.com");
sut.setRememberEmail(true);
await sut.saveEmailSettings();
const result = await firstValueFrom(storedEmailState.state$);
expect(result).toEqual("userEmail@bitwarden.com");
});
it("does not clear the email when adding an account and rememberEmail is false", async () => {
storedEmailState.stateSubject.next("initialEmail@bitwarden.com");
mockAuthStatuses$.next({
[userId]: AuthenticationStatus.Unlocked,
["OtherUserId" as UserId]: AuthenticationStatus.Locked,
});
sut.setEmail("userEmail@bitwarden.com");
sut.setRememberEmail(false);
await sut.saveEmailSettings();
const result = await firstValueFrom(storedEmailState.state$);
// result should not be null
expect(result).toEqual("initialEmail@bitwarden.com");
});
it("clears the email and rememberEmail after saving", async () => {
sut.setEmail("userEmail@bitwarden.com");
sut.setRememberEmail(true);
await sut.saveEmailSettings();
const result = sut.getEmail();
expect(result).toBeNull();
});
});
});

View File

@ -1,4 +1,8 @@
import { Observable } from "rxjs"; import { Observable, firstValueFrom, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { import {
GlobalState, GlobalState,
@ -8,20 +12,49 @@ import {
} from "../../../../../common/src/platform/state"; } from "../../../../../common/src/platform/state";
import { LoginEmailServiceAbstraction } from "../../abstractions/login-email.service"; import { LoginEmailServiceAbstraction } from "../../abstractions/login-email.service";
const STORED_EMAIL = new KeyDefinition<string>(LOGIN_EMAIL_DISK, "storedEmail", { export const STORED_EMAIL = new KeyDefinition<string>(LOGIN_EMAIL_DISK, "storedEmail", {
deserializer: (value: string) => value, deserializer: (value: string) => value,
}); });
export class LoginEmailService implements LoginEmailServiceAbstraction { export class LoginEmailService implements LoginEmailServiceAbstraction {
private email: string; private email: string | null;
private rememberEmail: boolean; private rememberEmail: boolean;
private readonly storedEmailState: GlobalState<string>; // True if an account is currently being added through account switching
storedEmail$: Observable<string>; private readonly addingAccount$: Observable<boolean>;
constructor(private stateProvider: StateProvider) { private readonly storedEmailState: GlobalState<string>;
storedEmail$: Observable<string | null>;
constructor(
private accountService: AccountService,
private authService: AuthService,
private stateProvider: StateProvider,
) {
this.storedEmailState = this.stateProvider.getGlobal(STORED_EMAIL); this.storedEmailState = this.stateProvider.getGlobal(STORED_EMAIL);
this.storedEmail$ = this.storedEmailState.state$;
// In order to determine if an account is being added, we check if any account is not logged out
this.addingAccount$ = this.authService.authStatuses$.pipe(
switchMap(async (statuses) => {
// We don't want to consider the active account since it may have just changed auth status to logged in
// which would make this observable think an account is being added
const activeUser = await firstValueFrom(this.accountService.activeAccount$);
if (activeUser) {
delete statuses[activeUser.id];
}
return Object.values(statuses).some((status) => status !== AuthenticationStatus.LoggedOut);
}),
);
this.storedEmail$ = this.storedEmailState.state$.pipe(
switchMap(async (storedEmail) => {
// When adding an account, we don't show the stored email
if (await firstValueFrom(this.addingAccount$)) {
return null;
}
return storedEmail;
}),
);
} }
getEmail() { getEmail() {
@ -37,16 +70,31 @@ export class LoginEmailService implements LoginEmailServiceAbstraction {
} }
setRememberEmail(value: boolean) { setRememberEmail(value: boolean) {
this.rememberEmail = value; this.rememberEmail = value ?? false;
} }
clearValues() { clearValues() {
this.email = null; this.email = null;
this.rememberEmail = null; this.rememberEmail = false;
} }
async saveEmailSettings() { async saveEmailSettings() {
await this.storedEmailState.update(() => (this.rememberEmail ? this.email : null)); const addingAccount = await firstValueFrom(this.addingAccount$);
await this.storedEmailState.update((storedEmail) => {
// If we're adding an account, only overwrite the stored email when rememberEmail is true
if (addingAccount) {
if (this.rememberEmail) {
return this.email;
}
return storedEmail;
}
// Logging in with rememberEmail set to false will clear the stored email
if (this.rememberEmail) {
return this.email;
}
return null;
});
this.clearValues(); this.clearValues();
} }
} }