1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-01-27 22:11:38 +01:00

fix(auth): [PM-15987] improve email/master password entry back/forward 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
This commit is contained in:
Alec Rippberger 2025-01-06 13:01:56 -06:00 committed by GitHub
parent ea10c29024
commit 26f086368b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 103 additions and 76 deletions
libs/auth/src/angular/login

View File

@ -20,8 +20,8 @@
formControlName="email"
bitInput
appAutofocus
(blur)="onEmailBlur($event)"
(keyup.enter)="continue()"
(input)="onEmailInput($event)"
(keyup.enter)="continuePressed()"
/>
</bit-form-field>
@ -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>
@ -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>

View File

@ -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";
@ -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";
@ -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;
@ -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
@ -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,
@ -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);
@ -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> {
@ -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(
@ -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;
}
@ -404,7 +410,10 @@ 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);
}
}
}
@ -412,11 +421,10 @@ export class LoginComponent implements OnInit, OnDestroy {
* 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() {
@ -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]);
}
protected async saveEmailSettings(): Promise<void> {
await this.loginEmailService.setLoginEmail(this.formGroup.value.email);
this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail);
await this.loginEmailService.setLoginEmail(email);
this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail ?? false);
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);
}
}
@ -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);
@ -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;
@ -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
@ -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",
);
}
}