Skip to content

Commit

Permalink
fix(auth): [PM-15987] improve email/master password entry back/forwar…
Browse files Browse the repository at this point in the history
…d navigation

- Fix back button behavior in Safari to reliably return to email entry screen
- Enable browser forward button after navigating back to email entry
- Move email validation to input event instead of blur
- Add continueClicked function to differentiate user clicks vs browser navigation
- Add email verification gate to SSO route
- Enhance master password validation logic
- Fix strict typing errors

Resolves PM-15987
  • Loading branch information
alec-livefront authored Jan 6, 2025
1 parent ea10c29 commit 26f0863
Show file tree
Hide file tree
Showing 2 changed files with 102 additions and 75 deletions.
37 changes: 7 additions & 30 deletions libs/auth/src/angular/login/login.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
formControlName="email"
bitInput
appAutofocus
(blur)="onEmailBlur($event)"
(keyup.enter)="continue()"
(input)="onEmailInput($event)"
(keyup.enter)="continuePressed()"
/>
</bit-form-field>

Expand All @@ -33,7 +33,7 @@

<div class="tw-grid tw-gap-3">
<!-- Continue button -->
<button type="button" bitButton block buttonType="primary" (click)="continue()">
<button type="button" bitButton block buttonType="primary" (click)="continuePressed()">
{{ "continue" | i18n }}
</button>

Expand All @@ -54,33 +54,10 @@
</ng-container>

<!-- Button to Login with SSO -->
<ng-container *ngIf="clientType === ClientType.Web">
<a
bitButton
block
buttonType="secondary"
routerLink="/sso"
[queryParams]="formGroup.value.email ? { email: formGroup.value.email } : {}"
(click)="saveEmailSettings()"
>
<i class="bwi bwi-provider tw-mr-1"></i>
{{ "useSingleSignOn" | i18n }}
</a>
</ng-container>
<ng-container *ngIf="clientType === ClientType.Browser || clientType === ClientType.Desktop">
<button
type="button"
bitButton
block
buttonType="secondary"
(click)="
launchSsoBrowserWindow(clientType === ClientType.Browser ? 'browser' : 'desktop')
"
>
<i class="bwi bwi-provider tw-mr-1"></i>
{{ "useSingleSignOn" | i18n }}
</button>
</ng-container>
<button type="button" bitButton block buttonType="secondary" (click)="handleSsoClick()">
<i class="bwi bwi-provider tw-mr-1"></i>
{{ "useSingleSignOn" | i18n }}
</button>
</div>
</div>

Expand Down
140 changes: 95 additions & 45 deletions libs/auth/src/angular/login/login.component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
Expand All @@ -12,7 +10,6 @@ import {
LoginStrategyServiceAbstraction,
LoginSuccessHandlerService,
PasswordLoginCredentials,
RegisterRouteService,
} from "@bitwarden/auth/common";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
Expand Down Expand Up @@ -72,16 +69,15 @@ export enum LoginUiState {
],
})
export class LoginComponent implements OnInit, OnDestroy {
@ViewChild("masterPasswordInputRef") masterPasswordInputRef: ElementRef;
@ViewChild("masterPasswordInputRef") masterPasswordInputRef: ElementRef | undefined;

private destroy$ = new Subject<void>();
private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions = undefined;
private enforcedMasterPasswordOptions: MasterPasswordPolicyOptions | undefined = undefined;
readonly Icons = { WaveIcon, VaultIcon };

clientType: ClientType;
ClientType = ClientType;
LoginUiState = LoginUiState;
registerRoute$ = this.registerRouteService.registerRoute$(); // TODO: remove when email verification flag is removed
isKnownDevice = false;
loginUiState: LoginUiState = LoginUiState.EMAIL_ENTRY;

Expand All @@ -97,13 +93,13 @@ export class LoginComponent implements OnInit, OnDestroy {
{ updateOn: "submit" },
);

get emailFormControl(): FormControl<string> {
get emailFormControl(): FormControl<string | null> {
return this.formGroup.controls.email;
}

// Web properties
enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions;
policies: Policy[];
enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions | undefined;
policies: Policy[] | undefined;
showResetPasswordAutoEnrollWarning = false;

// Desktop properties
Expand All @@ -125,7 +121,6 @@ export class LoginComponent implements OnInit, OnDestroy {
private passwordStrengthService: PasswordStrengthServiceAbstraction,
private platformUtilsService: PlatformUtilsService,
private policyService: InternalPolicyService,
private registerRouteService: RegisterRouteService,
private router: Router,
private toastService: ToastService,
private logService: LogService,
Expand Down Expand Up @@ -200,12 +195,12 @@ export class LoginComponent implements OnInit, OnDestroy {
return;
}

const credentials = new PasswordLoginCredentials(
email,
masterPassword,
null, // captcha no longer used in new login / registration scenarios
null,
);
if (!email || !masterPassword) {
this.logService.error("Email and master password are required");
return;
}

const credentials = new PasswordLoginCredentials(email, masterPassword);

try {
const authResult = await this.loginStrategyService.logIn(credentials);
Expand Down Expand Up @@ -301,7 +296,12 @@ export class LoginComponent implements OnInit, OnDestroy {
}

protected async launchSsoBrowserWindow(clientId: "browser" | "desktop"): Promise<void> {
await this.loginComponentService.launchSsoBrowserWindow(this.emailFormControl.value, clientId);
const email = this.emailFormControl.value;
if (!email) {
this.logService.error("Email is required for SSO login");
return;
}
await this.loginComponentService.launchSsoBrowserWindow(email, clientId);
}

protected async evaluatePassword(): Promise<void> {
Expand Down Expand Up @@ -337,9 +337,14 @@ export class LoginComponent implements OnInit, OnDestroy {

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,
this.formGroup.value.email ?? undefined,
)?.score;

return !this.policyService.evaluateMasterPassword(
Expand All @@ -363,6 +368,7 @@ export class LoginComponent implements OnInit, OnDestroy {

protected async validateEmail(): Promise<boolean> {
this.formGroup.controls.email.markAsTouched();
this.formGroup.controls.email.updateValueAndValidity({ onlySelf: true, emitEvent: true });
return this.formGroup.controls.email.valid;
}

Expand Down Expand Up @@ -404,19 +410,21 @@ export class LoginComponent implements OnInit, OnDestroy {
}

// Check to see if the device is known so we can show the Login with Device option
await this.getKnownDevice(this.emailFormControl.value);
const email = this.emailFormControl.value;
if (email) {
await this.getKnownDevice(email);
}
}
}

/**
* Set the email value from the input field.
* @param event The event object from the input field.
*/
onEmailBlur(event: Event) {
onEmailInput(event: Event) {
const emailInput = event.target as HTMLInputElement;
this.formGroup.controls.email.setValue(emailInput.value);
// Call setLoginEmail so that the email is pre-populated when navigating to the "enter password" screen.
this.loginEmailService.setLoginEmail(this.formGroup.value.email);
this.loginEmailService.setLoginEmail(emailInput.value);
}

isLoginWithPasskeySupported() {
Expand All @@ -428,28 +436,36 @@ export class LoginComponent implements OnInit, OnDestroy {
await this.router.navigateByUrl("/hint");
}

protected async goToRegister(): Promise<void> {
// TODO: remove when email verification flag is removed
const registerRoute = await firstValueFrom(this.registerRoute$);

if (this.emailFormControl.valid) {
await this.router.navigate([registerRoute], {
queryParams: { email: this.emailFormControl.value },
});
protected async saveEmailSettings(): Promise<void> {
const email = this.formGroup.value.email;
if (!email) {
this.logService.error("Email is required to save email settings.");
return;
}

await this.router.navigate([registerRoute]);
await this.loginEmailService.setLoginEmail(email);
this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail ?? false);
await this.loginEmailService.saveEmailSettings();
}

protected async saveEmailSettings(): Promise<void> {
await this.loginEmailService.setLoginEmail(this.formGroup.value.email);
this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail);
await this.loginEmailService.saveEmailSettings();
/**
* 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> {
if (await this.validateEmail()) {
const isEmailValid = await this.validateEmail();

if (isEmailValid) {
await this.toggleLoginUiState(LoginUiState.MASTER_PASSWORD_ENTRY);
}
}
Expand All @@ -460,6 +476,11 @@ export class LoginComponent implements OnInit, OnDestroy {
* @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);
Expand Down Expand Up @@ -503,7 +524,7 @@ export class LoginComponent implements OnInit, OnDestroy {
const orgPolicies = await this.loginComponentService.getOrgPolicies();

this.policies = orgPolicies?.policies;
this.showResetPasswordAutoEnrollWarning = orgPolicies?.isPolicyAndAutoEnrollEnabled;
this.showResetPasswordAutoEnrollWarning = orgPolicies?.isPolicyAndAutoEnrollEnabled ?? false;

let paramEmailIsSet = false;

Expand All @@ -525,7 +546,9 @@ export class LoginComponent implements OnInit, OnDestroy {
}

// Check to see if the device is known so that we can show the Login with Device option
await this.getKnownDevice(this.emailFormControl.value);
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
Expand Down Expand Up @@ -573,23 +596,50 @@ export class LoginComponent implements OnInit, OnDestroy {
* Handle the back button click to transition back to the email entry state.
*/
protected async backButtonClicked() {
// Replace the history so the "forward" button doesn't show (which wouldn't do anything)
history.pushState(null, "", window.location.pathname);
await this.toggleLoginUiState(LoginUiState.EMAIL_ENTRY);
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 = (event: PopStateEvent) => {
private handlePopState = async (event: PopStateEvent) => {
if (this.loginUiState === LoginUiState.MASTER_PASSWORD_ENTRY) {
// Prevent default navigation
// Prevent default navigation when the browser's back button is clicked
event.preventDefault();
// Replace the history so the "forward" button doesn't show (which wouldn't do anything)
history.pushState(null, "", window.location.pathname);
// 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();
}
};

/**
* Handle the SSO button click.
* @param event - The event object.
*/
async handleSsoClick() {
const isEmailValid = await this.validateEmail();

if (!isEmailValid) {
return;
}

await this.saveEmailSettings();

if (this.clientType === ClientType.Web) {
await this.router.navigate(["/sso"], {
queryParams: { email: this.formGroup.value.email },
});
return;
}

await this.launchSsoBrowserWindow(
this.clientType === ClientType.Browser ? "browser" : "desktop",
);
}
}

0 comments on commit 26f0863

Please sign in to comment.