mirror of
https://github.com/bitwarden/clients.git
synced 2026-02-09 10:23:47 +08:00
* feat(change-password-component): Change Password Update [18720] - Very close to complete. * fix(policy-enforcement): [PM-21085] Fix Bug with Policy Enforcement - Removed temp code to force the state I need to verify correctness. * fix(policy-enforcement): [PM-21085] Fix Bug with Policy Enforcement - Recover account working with change password component. * fix(policy-enforcement): [PM-21085] Fix Bug with Policy Enforcement - Made code more dry. * fix(change-password-component): Change Password Update [18720] - Updates to routing and the extension. Extension is still a wip. * fix(change-password-component): Change Password Update [18720] - Extension routing changes. * feat(change-password-component): Change Password Update [18720] - More extension work * feat(change-password-component): Change Password Update [18720] - Pausing work for now while we wait for product to hear back. * feat(change-password-component): Change Password Update [18720] - Removed duplicated anon layouts. * feat(change-password-component): Change Password Update [18720] - Tidied up code. * feat(change-password-component): Change Password Update [18720] - Small fixes to the styling * feat(change-password-component): Change Password Update [18720] - Adding more content for the routing. * feat(change-password-component): Change Password Update [18720] - Removed circular loop for now. * feat(change-password-component): Change Password Update [18720] - Made comments regarding the change password routing complexities with change-password and auth guard. * feat(change-password-component): Change Password Update [18720] - Undid some changes because they will be conflicts later on. * feat(change-password-component): Change Password Update [18720] - Small directive change. * feat(change-password-component): Change Password Update [18720] - Small changes and added some clarification on where I'm blocked * feat(change-password-component): Change Password Update [18720] - Org invite is seemingly working, found one bug to iron out. * refactor(change-password-component): Change Password Update [18720] - Fixed up policy service to be made more clear. * docs(change-password-component): Change Password Update [18720] - Updated documentation. * refactor(change-password-component): Change Password Update [18720] - Routing changes and policy service changes. * fix(change-password-component): Change Password Update [18720] - Wrapping up changes. * feat(change-password-component): Change Password Update [18720] - Should be working fully * feat(change-password-component): Change Password Update [18720] - Found a bug, working on password policy being present on login. * feat(change-password-component): Change Password Update [18720] - Turned on auth guard on other clients for change-password route. * feat(change-password-component): Change Password Update [18720] - Committing intermediate changes. * feat(change-password-component): Change Password Update [18720] - The master password policy endpoint has been added! Should be working. Testing now. * feat(change-password-component): Change Password Update [18720] - Minor fixes. * feat(change-password-component): Change Password Update [18720] - Undid naming change. * feat(change-password-component): Change Password Update [18720] - Removed comment. * feat(change-password-component): Change Password Update [18720] - Removed unneeded code. * fix(change-password-component): Change Password Update [18720] - Took org invite state out of service and made it accessible. * fix(change-password-component): Change Password Update [18720] - Small changes. * fix(change-password-component): Change Password Update [18720] - Split up org invite service into client specific implementations and have them injected into clients properly * feat(change-password-component): Change Password Update [18720] - Stopping work and going to switch to a new branch to pare down some of the solutions that were made to get this over the finish line * feat(change-password-component): Change Password Update [18720] - Started to remove functionality in the login.component and the password login strategy. * feat(change-password-component): Change Password Update [18720] - Removed more unneded changes. * feat(change-password-component): Change Password Update [18720] - Change password clearing state working properly. * fix(change-password-component): Change Password Update [18720] - Added docs and moved web implementation. * comments(change-password-component): Change Password Update [18720] - Added more notes. * test(change-password-component): Change Password Update [18720] - Added in tests for policy service. * comment(change-password-component): Change Password Update [18720] - Updated doc with correct ticket number. * comment(change-password-component): Change Password Update [18720] - Fixed doc. * test(change-password-component): Change Password Update [18720] - Fixed tests. * test(change-password-component): Change Password Update [18720] - Fixed linting errors. Have more tests to fix. * test(change-password-component): Change Password Update [18720] - Added back in ignore for typesafety. * fix(change-password-component): Change Password Update [18720] - Fixed other type issues. * test(change-password-component): Change Password Update [18720] - Fixed tests. * test(change-password-component): Change Password Update [18720] - Fixed more tests. * test(change-password-component): Change Password Update [18720] - Fixed tiny duplicate code. * fix(change-password-component): Change Password Update [18720] - Fixed desktop component. * fix(change-password-component): Change Password Update [18720] - Removed unused code * fix(change-password-component): Change Password Update [18720] - Fixed locales. * fix(change-password-component): Change Password Update [18720] - Removed tracing. * fix(change-password-component): Change Password Update [18720] - Removed duplicative services module entry. * fix(change-password-component): Change Password Update [18720] - Added comment. * fix(change-password-component): Change Password Update [18720] - Fixed unneeded call in two factor to get user id. * fix(change-password-component): Change Password Update [18720] - Fixed a couple of tiny things. * fix(change-password-component): Change Password Update [18720] - Added comment for later fix. * fix(change-password-component): Change Password Update [18720] - Fixed linting error. * PM-18720 - AuthGuard - move call to get isChangePasswordFlagOn down after other conditions for efficiency. * PM-18720 - PasswordLoginStrategy tests - test new feature flagged combine org invite policies logic for weak password evaluation. * PM-18720 - CLI - fix dep issue * PM-18720 - ChangePasswordComp - extract change password warning up out of input password component * PM-18720 - InputPassword - remove unused dependency. * PM-18720 - ChangePasswordComp - add callout dep * PM-18720 - Revert all anon-layout changes * PM-18720 - Anon Layout - finish reverting changes. * PM-18720 - WIP move of change password out of libs/auth * PM-18720 - Clean up remaining imports from moving change password out of libs/auth * PM-18720 - Add change-password barrel file for better import grouping * PM-18720 - Change Password comp - restore maxWidth * PM-18720 - After merge, fix errors * PM-18720 - Desktop - fix api service import * PM-18720 - NDV - fix routing. * PM-18720 - Change Password Comp - add logout service todo * PM-18720 - PasswordSettings - per feedback, component is already feature flagged behind PM16117_ChangeExistingPasswordRefactor so we can just delete the replaced callout (new text is in change-password comp) * PM-18720 - Routing Modules - properly flag new component behind feature flag. * PM-18720 - SSO Login Strategy - fix config service import since it is now in shared deps from main merge. * PM-18720 - Fix SSO login strategy tests * PM-18720 - Default Policy Service - address AC PR feedback --------- Co-authored-by: Jared Snider <jsnider@bitwarden.com> Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com>
683 lines
25 KiB
TypeScript
683 lines
25 KiB
TypeScript
import { CommonModule } from "@angular/common";
|
|
import { Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
|
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
|
|
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
|
|
import { firstValueFrom, Subject, take, takeUntil } from "rxjs";
|
|
|
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
|
import {
|
|
LoginEmailServiceAbstraction,
|
|
LoginStrategyServiceAbstraction,
|
|
LoginSuccessHandlerService,
|
|
PasswordLoginCredentials,
|
|
} from "@bitwarden/auth/common";
|
|
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
|
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
|
|
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
|
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
|
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
|
|
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
|
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
|
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
|
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
|
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
|
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
|
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
|
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
|
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
|
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
|
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
|
import { UserId } from "@bitwarden/common/types/guid";
|
|
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import {
|
|
AnonLayoutWrapperDataService,
|
|
AsyncActionsModule,
|
|
ButtonModule,
|
|
CheckboxModule,
|
|
FormFieldModule,
|
|
IconButtonModule,
|
|
LinkModule,
|
|
ToastService,
|
|
} from "@bitwarden/components";
|
|
|
|
import { VaultIcon, WaveIcon } from "../icons";
|
|
|
|
import { LoginComponentService, PasswordPolicies } from "./login-component.service";
|
|
|
|
const BroadcasterSubscriptionId = "LoginComponent";
|
|
|
|
// FIXME: update to use a const object instead of a typescript enum
|
|
// eslint-disable-next-line @bitwarden/platform/no-enums
|
|
export enum LoginUiState {
|
|
EMAIL_ENTRY = "EmailEntry",
|
|
MASTER_PASSWORD_ENTRY = "MasterPasswordEntry",
|
|
}
|
|
|
|
@Component({
|
|
templateUrl: "./login.component.html",
|
|
imports: [
|
|
AsyncActionsModule,
|
|
ButtonModule,
|
|
CheckboxModule,
|
|
CommonModule,
|
|
FormFieldModule,
|
|
IconButtonModule,
|
|
LinkModule,
|
|
JslibModule,
|
|
ReactiveFormsModule,
|
|
RouterModule,
|
|
],
|
|
})
|
|
export class LoginComponent implements OnInit, OnDestroy {
|
|
@ViewChild("masterPasswordInputRef") masterPasswordInputRef: ElementRef | undefined;
|
|
|
|
private destroy$ = new Subject<void>();
|
|
readonly Icons = { WaveIcon, VaultIcon };
|
|
|
|
clientType: ClientType;
|
|
ClientType = ClientType;
|
|
LoginUiState = LoginUiState;
|
|
isKnownDevice = false;
|
|
loginUiState: LoginUiState = LoginUiState.EMAIL_ENTRY;
|
|
|
|
formGroup = this.formBuilder.group(
|
|
{
|
|
email: ["", [Validators.required, Validators.email]],
|
|
masterPassword: [
|
|
"",
|
|
[Validators.required, Validators.minLength(Utils.originalMinimumPasswordLength)],
|
|
],
|
|
rememberEmail: [false],
|
|
},
|
|
{ updateOn: "submit" },
|
|
);
|
|
|
|
get emailFormControl(): FormControl<string | null> {
|
|
return this.formGroup.controls.email;
|
|
}
|
|
|
|
// Desktop properties
|
|
deferFocus: boolean | null = null;
|
|
|
|
constructor(
|
|
private activatedRoute: ActivatedRoute,
|
|
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
|
|
private appIdService: AppIdService,
|
|
private broadcasterService: BroadcasterService,
|
|
private devicesApiService: DevicesApiServiceAbstraction,
|
|
private formBuilder: FormBuilder,
|
|
private i18nService: I18nService,
|
|
private loginEmailService: LoginEmailServiceAbstraction,
|
|
private loginComponentService: LoginComponentService,
|
|
private loginStrategyService: LoginStrategyServiceAbstraction,
|
|
private messagingService: MessagingService,
|
|
private ngZone: NgZone,
|
|
private passwordStrengthService: PasswordStrengthServiceAbstraction,
|
|
private platformUtilsService: PlatformUtilsService,
|
|
private policyService: InternalPolicyService,
|
|
private router: Router,
|
|
private toastService: ToastService,
|
|
private logService: LogService,
|
|
private validationService: ValidationService,
|
|
private loginSuccessHandlerService: LoginSuccessHandlerService,
|
|
private masterPasswordService: MasterPasswordServiceAbstraction,
|
|
private configService: ConfigService,
|
|
) {
|
|
this.clientType = this.platformUtilsService.getClientType();
|
|
}
|
|
|
|
async ngOnInit(): Promise<void> {
|
|
// Add popstate listener to listen for browser back button clicks
|
|
window.addEventListener("popstate", this.handlePopState);
|
|
|
|
await this.defaultOnInit();
|
|
|
|
if (this.clientType === ClientType.Desktop) {
|
|
await this.desktopOnInit();
|
|
}
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
// Remove popstate listener
|
|
window.removeEventListener("popstate", this.handlePopState);
|
|
|
|
if (this.clientType === ClientType.Desktop) {
|
|
// TODO: refactor to not use deprecated broadcaster service.
|
|
this.broadcasterService.unsubscribe(BroadcasterSubscriptionId);
|
|
}
|
|
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
}
|
|
|
|
private async defaultOnInit(): Promise<void> {
|
|
let paramEmailIsSet = false;
|
|
|
|
const params = await firstValueFrom(this.activatedRoute.queryParams);
|
|
|
|
if (params) {
|
|
const qParamsEmail = params.email;
|
|
|
|
// If there is an email in the query params, set that email as the form field value
|
|
if (qParamsEmail != null && qParamsEmail.indexOf("@") > -1) {
|
|
this.formGroup.controls.email.setValue(qParamsEmail);
|
|
paramEmailIsSet = true;
|
|
}
|
|
}
|
|
|
|
// If there are no params or no email in the query params, loadEmailSettings from state
|
|
if (!paramEmailIsSet) {
|
|
await this.loadRememberedEmail();
|
|
}
|
|
|
|
// Check to see if the device is known so that we can show the Login with Device option
|
|
if (this.emailFormControl.value) {
|
|
await this.getKnownDevice(this.emailFormControl.value);
|
|
}
|
|
|
|
// Backup check to handle unknown case where activatedRoute is not available
|
|
// This shouldn't happen under normal circumstances
|
|
if (!this.activatedRoute) {
|
|
await this.loadRememberedEmail();
|
|
}
|
|
}
|
|
|
|
private async desktopOnInit(): Promise<void> {
|
|
// TODO: refactor to not use deprecated broadcaster service.
|
|
this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => {
|
|
this.ngZone.run(() => {
|
|
switch (message.command) {
|
|
case "windowIsFocused":
|
|
if (this.deferFocus === null) {
|
|
this.deferFocus = !message.windowIsFocused;
|
|
if (!this.deferFocus) {
|
|
this.focusInput();
|
|
}
|
|
} else if (this.deferFocus && message.windowIsFocused) {
|
|
this.focusInput();
|
|
this.deferFocus = false;
|
|
}
|
|
break;
|
|
default:
|
|
}
|
|
});
|
|
});
|
|
|
|
this.messagingService.send("getWindowIsFocused");
|
|
}
|
|
|
|
submit = async (): Promise<void> => {
|
|
if (this.clientType === ClientType.Desktop) {
|
|
if (this.loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const { email, masterPassword } = this.formGroup.value;
|
|
|
|
this.formGroup.markAllAsTouched();
|
|
if (this.formGroup.invalid) {
|
|
return;
|
|
}
|
|
|
|
if (!email || !masterPassword) {
|
|
this.logService.error("Email and master password are required");
|
|
return;
|
|
}
|
|
|
|
let credentials: PasswordLoginCredentials;
|
|
|
|
if (
|
|
await this.configService.getFeatureFlag(FeatureFlag.PM16117_ChangeExistingPasswordRefactor)
|
|
) {
|
|
// Try to retrieve any org policies from an org invite now so we can send it to the
|
|
// login strategies. Since it is optional and we only want to be doing this on the
|
|
// web we will only send in content in the right context.
|
|
const orgPoliciesFromInvite = this.loginComponentService.getOrgPoliciesFromOrgInvite
|
|
? await this.loginComponentService.getOrgPoliciesFromOrgInvite()
|
|
: null;
|
|
|
|
const orgMasterPasswordPolicyOptions = orgPoliciesFromInvite?.enforcedPasswordPolicyOptions;
|
|
|
|
credentials = new PasswordLoginCredentials(
|
|
email,
|
|
masterPassword,
|
|
undefined,
|
|
orgMasterPasswordPolicyOptions,
|
|
);
|
|
} else {
|
|
credentials = new PasswordLoginCredentials(email, masterPassword);
|
|
}
|
|
|
|
try {
|
|
const authResult = await this.loginStrategyService.logIn(credentials);
|
|
|
|
await this.handleAuthResult(authResult);
|
|
} catch (error) {
|
|
this.logService.error(error);
|
|
this.handleSubmitError(error);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handles the error from the submit function.
|
|
*
|
|
* @param error The error object.
|
|
*/
|
|
private handleSubmitError(error: unknown) {
|
|
// Handle error responses
|
|
if (error instanceof ErrorResponse) {
|
|
switch (error.statusCode) {
|
|
case HttpStatusCode.BadRequest: {
|
|
if (error.message.toLowerCase().includes("username or password is incorrect")) {
|
|
this.formGroup.controls.masterPassword.setErrors({
|
|
error: {
|
|
message: this.i18nService.t("invalidMasterPassword"),
|
|
},
|
|
});
|
|
} else {
|
|
// Allow other 400 responses to be handled by toast
|
|
this.validationService.showError(error);
|
|
}
|
|
break;
|
|
}
|
|
default: {
|
|
// Allow all other error codes to be handled by toast
|
|
this.validationService.showError(error);
|
|
}
|
|
}
|
|
} else {
|
|
// Allow all other errors to be handled by toast
|
|
this.validationService.showError(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles the result of the authentication process.
|
|
*
|
|
* @param authResult
|
|
* @returns A simple `return` statement for each conditional check.
|
|
* If you update this method, do not forget to add a `return`
|
|
* to each if-condition block where necessary to stop code execution.
|
|
*/
|
|
private async handleAuthResult(authResult: AuthResult): Promise<void> {
|
|
if (authResult.requiresEncryptionKeyMigration) {
|
|
/* Legacy accounts used the master key to encrypt data.
|
|
This is now unsupported and requires a downgraded client */
|
|
this.toastService.showToast({
|
|
variant: "error",
|
|
title: this.i18nService.t("errorOccurred"),
|
|
message: this.i18nService.t("legacyEncryptionUnsupported"),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (authResult.requiresTwoFactor) {
|
|
await this.router.navigate(["2fa"]);
|
|
return;
|
|
}
|
|
|
|
// Redirect to device verification if this is an unknown device
|
|
if (authResult.requiresDeviceVerification) {
|
|
await this.router.navigate(["device-verification"]);
|
|
return;
|
|
}
|
|
|
|
// User logged in successfully so execute side effects
|
|
await this.loginSuccessHandlerService.run(authResult.userId);
|
|
|
|
// Determine where to send the user next
|
|
// The AuthGuard will handle routing to update-temp-password based on state
|
|
|
|
// TODO: PM-18269 - evaluate if we can combine this with the
|
|
// password evaluation done in the password login strategy.
|
|
// If there's an existing org invite, use it to get the org's password policies
|
|
// so we can evaluate the MP against the org policies
|
|
if (this.loginComponentService.getOrgPoliciesFromOrgInvite) {
|
|
const orgPolicies: PasswordPolicies | null =
|
|
await this.loginComponentService.getOrgPoliciesFromOrgInvite();
|
|
|
|
if (orgPolicies) {
|
|
// Since we have retrieved the policies, we can go ahead and set them into state for future use
|
|
// e.g., the update-password page currently only references state for policy data and
|
|
// doesn't fallback to pulling them from the server like it should if they are null.
|
|
await this.setPoliciesIntoState(authResult.userId, orgPolicies.policies);
|
|
|
|
const isPasswordChangeRequired = await this.isPasswordChangeRequiredByOrgPolicy(
|
|
orgPolicies.enforcedPasswordPolicyOptions,
|
|
);
|
|
if (isPasswordChangeRequired) {
|
|
const changePasswordFeatureFlagOn = await this.configService.getFeatureFlag(
|
|
FeatureFlag.PM16117_ChangeExistingPasswordRefactor,
|
|
);
|
|
|
|
await this.router.navigate(
|
|
changePasswordFeatureFlagOn ? ["change-password"] : ["update-password"],
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.clientType === ClientType.Browser) {
|
|
await this.router.navigate(["/tabs/vault"]);
|
|
} else {
|
|
await this.router.navigate(["vault"]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the master password meets the enforced policy requirements
|
|
* and if the user is required to change their password.
|
|
*
|
|
* TODO: This is duplicate checking that we want to only do in the password login strategy.
|
|
* Once we no longer need the policies state being set to reference later in change password
|
|
* via using the Admin Console's new policy endpoint changes we can remove this. Consult
|
|
* PM-23001 for details.
|
|
*/
|
|
private async isPasswordChangeRequiredByOrgPolicy(
|
|
enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions,
|
|
): Promise<boolean> {
|
|
try {
|
|
if (enforcedPasswordPolicyOptions == undefined) {
|
|
return false;
|
|
}
|
|
|
|
// Note: we deliberately do not check enforcedPasswordPolicyOptions.enforceOnLogin
|
|
// as existing users who are logging in after getting an org invite should
|
|
// always be forced to set a password that meets the org's policy.
|
|
// Org Invite -> Registration also works this way for new BW users as well.
|
|
|
|
const masterPassword = this.formGroup.controls.masterPassword.value;
|
|
|
|
// Return false if masterPassword is null/undefined since this is only evaluated after successful login
|
|
if (!masterPassword) {
|
|
return false;
|
|
}
|
|
|
|
const passwordStrength = this.passwordStrengthService.getPasswordStrength(
|
|
masterPassword,
|
|
this.formGroup.value.email ?? undefined,
|
|
)?.score;
|
|
|
|
return !this.policyService.evaluateMasterPassword(
|
|
passwordStrength,
|
|
masterPassword,
|
|
enforcedPasswordPolicyOptions,
|
|
);
|
|
} catch (e) {
|
|
// Do not prevent unlock if there is an error evaluating policies
|
|
this.logService.error(e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async setPoliciesIntoState(userId: UserId, policies: Policy[]): Promise<void> {
|
|
const policiesData: { [id: string]: PolicyData } = {};
|
|
policies.map((p) => (policiesData[p.id] = PolicyData.fromPolicy(p)));
|
|
await this.policyService.replace(policiesData, userId);
|
|
}
|
|
|
|
protected async startAuthRequestLogin(): Promise<void> {
|
|
this.formGroup.get("masterPassword")?.clearValidators();
|
|
this.formGroup.get("masterPassword")?.updateValueAndValidity();
|
|
|
|
if (!this.formGroup.valid) {
|
|
return;
|
|
}
|
|
|
|
await this.router.navigate(["/login-with-device"]);
|
|
}
|
|
|
|
protected async toggleLoginUiState(value: LoginUiState): Promise<void> {
|
|
this.loginUiState = value;
|
|
|
|
if (this.loginUiState === LoginUiState.EMAIL_ENTRY) {
|
|
this.loginComponentService.showBackButton(false);
|
|
|
|
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
|
pageTitle: { key: "logInToBitwarden" },
|
|
pageIcon: this.Icons.VaultIcon,
|
|
pageSubtitle: null, // remove subtitle when going back to email entry
|
|
});
|
|
|
|
// Reset master password only when going from validated to not validated so that autofill can work properly
|
|
this.formGroup.controls.masterPassword.reset();
|
|
|
|
// Reset known device state when going back to email entry if it is supported
|
|
this.isKnownDevice = false;
|
|
} else if (this.loginUiState === LoginUiState.MASTER_PASSWORD_ENTRY) {
|
|
this.loginComponentService.showBackButton(true);
|
|
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({
|
|
pageTitle: { key: "welcomeBack" },
|
|
pageSubtitle: this.emailFormControl.value,
|
|
pageIcon: this.Icons.WaveIcon,
|
|
});
|
|
|
|
// Mark MP as untouched so that, when users enter email and hit enter, the MP field doesn't load with validation errors
|
|
this.formGroup.controls.masterPassword.markAsUntouched();
|
|
|
|
// When email is validated, focus on master password after waiting for input to be rendered
|
|
if (this.ngZone.isStable) {
|
|
this.masterPasswordInputRef?.nativeElement?.focus();
|
|
} else {
|
|
this.ngZone.onStable.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
|
|
this.masterPasswordInputRef?.nativeElement?.focus();
|
|
});
|
|
}
|
|
|
|
// Check to see if the device is known so we can show the Login with Device option
|
|
const email = this.emailFormControl.value;
|
|
if (email) {
|
|
await this.getKnownDevice(email);
|
|
}
|
|
}
|
|
}
|
|
|
|
isLoginWithPasskeySupported() {
|
|
return this.loginComponentService.isLoginWithPasskeySupported();
|
|
}
|
|
|
|
protected async goToHint(): Promise<void> {
|
|
await this.router.navigateByUrl("/hint");
|
|
}
|
|
|
|
/**
|
|
* Continue button clicked (or enter key pressed).
|
|
* Adds the login url to the browser's history so that the back button can be used to go back to the email entry state.
|
|
* Needs to be separate from the continue() function because that can be triggered by the browser's forward button.
|
|
*/
|
|
protected async continuePressed() {
|
|
// Add a new entry to the browser's history so that there is a history entry to go back to
|
|
history.pushState({}, "", window.location.href);
|
|
await this.continue();
|
|
}
|
|
|
|
/**
|
|
* Continue to the master password entry state (only if email is validated)
|
|
*/
|
|
protected async continue(): Promise<void> {
|
|
const isEmailValid = this.validateEmail();
|
|
|
|
if (isEmailValid) {
|
|
await this.toggleLoginUiState(LoginUiState.MASTER_PASSWORD_ENTRY);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle the Login with Passkey button click.
|
|
* We need a handler here in order to persist the remember email selection to state before routing.
|
|
* @param event - The event object.
|
|
*/
|
|
async handleLoginWithPasskeyClick() {
|
|
await this.router.navigate(["/login-with-passkey"]);
|
|
}
|
|
|
|
/**
|
|
* Handle the SSO button click.
|
|
* @param event - The event object.
|
|
*/
|
|
async handleSsoClick() {
|
|
// Make sure the email is valid
|
|
const isEmailValid = this.validateEmail();
|
|
if (!isEmailValid) {
|
|
return;
|
|
}
|
|
|
|
// Make sure the email is not empty, for type safety
|
|
const email = this.formGroup.value.email;
|
|
if (!email) {
|
|
this.logService.error("Email is required for SSO");
|
|
return;
|
|
}
|
|
|
|
// Send the user to SSO, either through routing or through redirecting to the web app
|
|
await this.loginComponentService.redirectToSsoLogin(email);
|
|
}
|
|
|
|
/**
|
|
* Call to check if the device is known.
|
|
* Known means that the user has logged in with this device before.
|
|
* @param email - The user's email
|
|
*/
|
|
private async getKnownDevice(email: string): Promise<void> {
|
|
if (!email) {
|
|
this.isKnownDevice = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const deviceIdentifier = await this.appIdService.getAppId();
|
|
this.isKnownDevice = await this.devicesApiService.getKnownDevice(email, deviceIdentifier);
|
|
// FIXME: Remove when updating file. Eslint update
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
} catch (e) {
|
|
this.isKnownDevice = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check to see if the user has remembered an email on the current device.
|
|
* If so, set the email in the form field and set rememberEmail to true. If not, set rememberEmail to false.
|
|
*/
|
|
private async loadRememberedEmail(): Promise<void> {
|
|
const storedEmail = await firstValueFrom(this.loginEmailService.rememberedEmail$);
|
|
if (storedEmail) {
|
|
this.formGroup.controls.email.setValue(storedEmail);
|
|
this.formGroup.controls.rememberEmail.setValue(true);
|
|
// If we load an email into the form, we need to initialize it for the login process as well
|
|
// so that other login components can use it.
|
|
// We do this here as it's possible that a user doesn't edit the email field before submitting.
|
|
await this.loginEmailService.setLoginEmail(storedEmail);
|
|
} else {
|
|
this.formGroup.controls.rememberEmail.setValue(false);
|
|
}
|
|
}
|
|
|
|
private focusInput() {
|
|
document
|
|
.getElementById(
|
|
this.emailFormControl.value == null || this.emailFormControl.value === ""
|
|
? "email"
|
|
: "masterPassword",
|
|
)
|
|
?.focus();
|
|
}
|
|
|
|
/**
|
|
* Helper function to determine if the back button should be shown.
|
|
* @returns true if the back button should be shown.
|
|
*/
|
|
protected shouldShowBackButton(): boolean {
|
|
return (
|
|
this.loginUiState === LoginUiState.MASTER_PASSWORD_ENTRY &&
|
|
this.clientType !== ClientType.Browser
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle the back button click to transition back to the email entry state.
|
|
*/
|
|
protected async backButtonClicked() {
|
|
history.back();
|
|
}
|
|
|
|
/**
|
|
* Handle the popstate event to transition back to the email entry state when the back button is clicked.
|
|
* Also handles the case where the user clicks the forward button.
|
|
* @param event - The popstate event.
|
|
*/
|
|
private handlePopState = async (event: PopStateEvent) => {
|
|
if (this.loginUiState === LoginUiState.MASTER_PASSWORD_ENTRY) {
|
|
// Prevent default navigation when the browser's back button is clicked
|
|
event.preventDefault();
|
|
// Transition back to email entry state
|
|
void this.toggleLoginUiState(LoginUiState.EMAIL_ENTRY);
|
|
} else if (this.loginUiState === LoginUiState.EMAIL_ENTRY) {
|
|
// Prevent default navigation when the browser's forward button is clicked
|
|
event.preventDefault();
|
|
// Continue to the master password entry state
|
|
await this.continue();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Validates the email and displays any validation errors.
|
|
* @returns true if the email is valid, false otherwise.
|
|
*/
|
|
protected validateEmail(): boolean {
|
|
this.formGroup.controls.email.markAsTouched();
|
|
this.formGroup.controls.email.updateValueAndValidity({ onlySelf: true, emitEvent: true });
|
|
return this.formGroup.controls.email.valid;
|
|
}
|
|
|
|
/**
|
|
* Persist the entered email address and the user's choice to remember it to state.
|
|
*/
|
|
private async persistEmailIfValid(): Promise<void> {
|
|
if (this.formGroup.controls.email.valid) {
|
|
const email = this.formGroup.value.email;
|
|
const rememberEmail = this.formGroup.value.rememberEmail ?? false;
|
|
if (!email) {
|
|
return;
|
|
}
|
|
await this.loginEmailService.setLoginEmail(email);
|
|
await this.loginEmailService.setRememberedEmailChoice(email, rememberEmail);
|
|
} else {
|
|
await this.loginEmailService.clearLoginEmail();
|
|
await this.loginEmailService.clearRememberedEmail();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the email value from the input field and persists to state if valid.
|
|
* We only update the form controls onSubmit instead of onBlur because we don't want to show validation errors until
|
|
* the user submits. This is because currently our validation errors are shown below the input fields, and
|
|
* displaying them causes the screen to "jump".
|
|
* @param event The event object from the input field.
|
|
*/
|
|
async onEmailInput(event: Event) {
|
|
const emailInput = event.target as HTMLInputElement;
|
|
this.formGroup.controls.email.setValue(emailInput.value);
|
|
await this.persistEmailIfValid();
|
|
}
|
|
|
|
/**
|
|
* Set the Remember Email value from the input field and persists to state if valid.
|
|
* We only update the form controls onSubmit instead of onBlur because we don't want to show validation errors until
|
|
* the user submits. This is because currently our validation errors are shown below the input fields, and
|
|
* displaying them causes the screen to "jump".
|
|
* @param event The event object from the input field.
|
|
*/
|
|
async onRememberEmailInput(event: Event) {
|
|
const rememberEmailInput = event.target as HTMLInputElement;
|
|
this.formGroup.controls.rememberEmail.setValue(rememberEmailInput.checked);
|
|
await this.persistEmailIfValid();
|
|
}
|
|
}
|