[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:
parent
5839999fc4
commit
052b3be2eb
|
@ -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
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>;
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue