From 4738b979e46da65125a0eeb265aeba7ca996a466 Mon Sep 17 00:00:00 2001 From: gbrodman Date: Mon, 18 Aug 2025 23:00:44 -0400 Subject: [PATCH] Add FE for password-reset verification (#2795) Tested locally and on alpha with dummy values (and throwing an exception). I was able to reuse a bit of code from the EPP password reset, but not all of it. --- console-webapp/src/app/app-routing.module.ts | 5 + console-webapp/src/app/app.module.ts | 6 +- .../security/eppPasswordEdit.component.html | 64 +---------- .../security/eppPasswordEdit.component.scss | 16 --- .../security/eppPasswordEdit.component.ts | 93 ++++------------ .../passwordInputForm.component.html | 63 +++++++++++ .../passwordInputForm.component.scss | 30 +++++ .../passwordInputForm.component.ts | 82 ++++++++++++++ .../passwordResetVerify.component.html | 27 +++++ .../passwordResetVerify.component.ts | 105 ++++++++++++++++++ .../app/shared/services/backend.service.ts | 16 +++ .../src/app/users/users.component.ts | 2 - .../console/PasswordResetVerifyAction.java | 2 + 13 files changed, 362 insertions(+), 149 deletions(-) create mode 100644 console-webapp/src/app/shared/components/passwordReset/passwordInputForm.component.html create mode 100644 console-webapp/src/app/shared/components/passwordReset/passwordInputForm.component.scss create mode 100644 console-webapp/src/app/shared/components/passwordReset/passwordInputForm.component.ts create mode 100644 console-webapp/src/app/shared/components/passwordReset/passwordResetVerify.component.html create mode 100644 console-webapp/src/app/shared/components/passwordReset/passwordResetVerify.component.ts diff --git a/console-webapp/src/app/app-routing.module.ts b/console-webapp/src/app/app-routing.module.ts index 715256826..ebabe0d9c 100644 --- a/console-webapp/src/app/app-routing.module.ts +++ b/console-webapp/src/app/app-routing.module.ts @@ -26,6 +26,7 @@ import SecurityComponent from './settings/security/security.component'; import { SettingsComponent } from './settings/settings.component'; import { SupportComponent } from './support/support.component'; import RdapComponent from './settings/rdap/rdap.component'; +import { PasswordResetVerifyComponent } from './shared/components/passwordReset/passwordResetVerify.component'; export interface RouteWithIcon extends Route { iconName?: string; @@ -38,6 +39,10 @@ export const PATHS = { }; export const routes: RouteWithIcon[] = [ { path: '', redirectTo: '/home', pathMatch: 'full' }, + { + path: PasswordResetVerifyComponent.PATH, + component: PasswordResetVerifyComponent, + }, { path: RegistryLockVerifyComponent.PATH, component: RegistryLockVerifyComponent, diff --git a/console-webapp/src/app/app.module.ts b/console-webapp/src/app/app.module.ts index 45cc67cd7..948e42b70 100644 --- a/console-webapp/src/app/app.module.ts +++ b/console-webapp/src/app/app.module.ts @@ -61,6 +61,8 @@ import { ForceFocusDirective } from './shared/directives/forceFocus.directive'; import RdapComponent from './settings/rdap/rdap.component'; import RdapEditComponent from './settings/rdap/rdapEdit.component'; import { PocReminderComponent } from './shared/components/pocReminder/pocReminder.component'; +import { PasswordResetVerifyComponent } from './shared/components/passwordReset/passwordResetVerify.component'; +import { PasswordInputForm } from './shared/components/passwordReset/passwordInputForm.component'; @NgModule({ declarations: [SelectedRegistrarWrapper], @@ -84,10 +86,12 @@ export class SelectedRegistrarModule {} NavigationComponent, NewRegistrarComponent, NotificationsComponent, + PasswordInputForm, + PasswordResetVerifyComponent, + PocReminderComponent, RdapComponent, RdapEditComponent, ReasonDialogComponent, - PocReminderComponent, RegistrarComponent, RegistrarDetailsComponent, RegistrarSelectorComponent, diff --git a/console-webapp/src/app/settings/security/eppPasswordEdit.component.html b/console-webapp/src/app/settings/security/eppPasswordEdit.component.html index 97a9c7120..93908ca51 100644 --- a/console-webapp/src/app/settings/security/eppPasswordEdit.component.html +++ b/console-webapp/src/app/settings/security/eppPasswordEdit.component.html @@ -16,67 +16,11 @@

Passwords must be between 6 and 16 alphanumeric characters

-
-
- - Old password: - - {{ - errorText - }} - -
-
- - New password: - - {{ - errorText - }} - -
-
- - Confirm new password: - - {{ - errorText - }} - -
- -
+ (submitResults)="save($event)" + /> @if(userDataService.userData()?.isAdmin) {

Need to reset your EPP password?

diff --git a/console-webapp/src/app/settings/security/eppPasswordEdit.component.scss b/console-webapp/src/app/settings/security/eppPasswordEdit.component.scss index 7b77a5e57..e5a9b087e 100644 --- a/console-webapp/src/app/settings/security/eppPasswordEdit.component.scss +++ b/console-webapp/src/app/settings/security/eppPasswordEdit.component.scss @@ -13,22 +13,6 @@ // limitations under the License. .settings-security { - &__edit-password { - max-width: 616px; - &-field { - width: 100%; - mat-form-field { - margin-bottom: 20px; - width: 100%; - } - } - &-form { - margin-top: 30px; - } - &-save { - margin-top: 30px; - } - } &__reset-password-field { margin-top: 60px; } diff --git a/console-webapp/src/app/settings/security/eppPasswordEdit.component.ts b/console-webapp/src/app/settings/security/eppPasswordEdit.component.ts index 73221b829..bf0c75ed7 100644 --- a/console-webapp/src/app/settings/security/eppPasswordEdit.component.ts +++ b/console-webapp/src/app/settings/security/eppPasswordEdit.component.ts @@ -14,13 +14,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Component } from '@angular/core'; -import { - AbstractControl, - FormControl, - FormGroup, - ValidatorFn, - Validators, -} from '@angular/forms'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; import { MatSnackBar } from '@angular/material/snack-bar'; import { RegistrarService } from 'src/app/registrar/registrar.service'; import { SecurityService } from './security.service'; @@ -30,10 +24,10 @@ import { CommonModule } from '@angular/common'; import { MaterialModule } from 'src/app/material.module'; import { filter, switchMap, take } from 'rxjs'; import { BackendService } from 'src/app/shared/services/backend.service'; - -type errorCode = 'required' | 'maxlength' | 'minlength' | 'passwordsDontMatch'; - -type errorFriendlyText = { [type in errorCode]: String }; +import { + PasswordInputForm, + PasswordResults, +} from 'src/app/shared/components/passwordReset/passwordInputForm.component'; @Component({ selector: 'app-reset-epp-password-dialog', @@ -68,16 +62,21 @@ export class ResetEppPasswordComponent { standalone: false, }) export default class EppPasswordEditComponent { - MIN_MAX_LENGHT = new String( - 'Passwords must be between 6 and 16 alphanumeric characters' - ); + static EPP_VALIDATORS = [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(16), + PasswordInputForm.newPasswordsMatch, + ]; - errorTextMap: errorFriendlyText = { - required: "This field can't be empty", - maxlength: this.MIN_MAX_LENGHT, - minlength: this.MIN_MAX_LENGHT, - passwordsDontMatch: "Passwords don't match", - }; + passwordUpdateForm = new FormGroup({ + oldPassword: new FormControl('', [Validators.required]), + newPassword: new FormControl('', EppPasswordEditComponent.EPP_VALIDATORS), + newPasswordRepeat: new FormControl( + '', + EppPasswordEditComponent.EPP_VALIDATORS + ), + }); constructor( public registrarService: RegistrarService, @@ -88,59 +87,13 @@ export default class EppPasswordEditComponent { private _snackBar: MatSnackBar ) {} - hasError(controlName: string) { - const maybeErrors = this.passwordUpdateForm.get(controlName)?.errors; - const maybeError = - maybeErrors && (Object.keys(maybeErrors)[0] as errorCode); - if (maybeError) { - return this.errorTextMap[maybeError]; - } - return ''; - } - - newPasswordsMatch: ValidatorFn = (control: AbstractControl) => { - if ( - this.passwordUpdateForm?.get('newPassword')?.value === - this.passwordUpdateForm?.get('newPasswordRepeat')?.value - ) { - this.passwordUpdateForm?.get('newPasswordRepeat')?.setErrors(null); - } else { - // latest angular just won't detect the error without setTimeout - setTimeout(() => { - this.passwordUpdateForm - ?.get('newPasswordRepeat') - ?.setErrors({ passwordsDontMatch: control.value }); - }); - } - return null; - }; - - passwordUpdateForm = new FormGroup({ - oldPassword: new FormControl('', [Validators.required]), - newPassword: new FormControl('', [ - Validators.required, - Validators.minLength(6), - Validators.maxLength(16), - this.newPasswordsMatch, - ]), - newPasswordRepeat: new FormControl('', [ - Validators.required, - Validators.minLength(6), - Validators.maxLength(16), - this.newPasswordsMatch, - ]), - }); - - save() { - const { oldPassword, newPassword, newPasswordRepeat } = - this.passwordUpdateForm.value; - if (!oldPassword || !newPassword || !newPasswordRepeat) return; + save(passwordResults: PasswordResults) { this.securityService .saveEppPassword({ registrarId: this.registrarService.registrarId(), - oldPassword, - newPassword, - newPasswordRepeat, + oldPassword: passwordResults.oldPassword!, + newPassword: passwordResults.newPassword, + newPasswordRepeat: passwordResults.newPasswordRepeat, }) .subscribe({ complete: () => { diff --git a/console-webapp/src/app/shared/components/passwordReset/passwordInputForm.component.html b/console-webapp/src/app/shared/components/passwordReset/passwordInputForm.component.html new file mode 100644 index 000000000..4d01a49b0 --- /dev/null +++ b/console-webapp/src/app/shared/components/passwordReset/passwordInputForm.component.html @@ -0,0 +1,63 @@ +
+ @if (displayOldPasswordField()) { +
+ + Old password: + + {{ + errorText + }} + +
+ } +
+ + New password: + + {{ + errorText + }} + +
+
+ + Confirm new password: + + {{ + errorText + }} + +
+ +
diff --git a/console-webapp/src/app/shared/components/passwordReset/passwordInputForm.component.scss b/console-webapp/src/app/shared/components/passwordReset/passwordInputForm.component.scss new file mode 100644 index 000000000..b8a123344 --- /dev/null +++ b/console-webapp/src/app/shared/components/passwordReset/passwordInputForm.component.scss @@ -0,0 +1,30 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +.console-app__password-input-form { + max-width: 450px; + &-field { + width: 100%; + mat-form-field { + margin-bottom: 20px; + width: 100%; + } + } + &-form { + margin-top: 30px; + } + &-save { + margin-top: 30px; + } +} diff --git a/console-webapp/src/app/shared/components/passwordReset/passwordInputForm.component.ts b/console-webapp/src/app/shared/components/passwordReset/passwordInputForm.component.ts new file mode 100644 index 000000000..182eca812 --- /dev/null +++ b/console-webapp/src/app/shared/components/passwordReset/passwordInputForm.component.ts @@ -0,0 +1,82 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, EventEmitter, input, Output } from '@angular/core'; +import { AbstractControl, FormGroup, ValidatorFn } from '@angular/forms'; + +type errorCode = 'required' | 'maxlength' | 'minlength' | 'passwordsDontMatch'; + +type errorFriendlyText = { [type in errorCode]: String }; + +export interface PasswordResults { + oldPassword: string | null; + newPassword: string; + newPasswordRepeat: string; +} + +@Component({ + selector: 'password-input-form-component', + templateUrl: './passwordInputForm.component.html', + styleUrls: ['./passwordInputForm.component.scss'], + standalone: false, +}) +export class PasswordInputForm { + static newPasswordsMatch: ValidatorFn = (control: AbstractControl) => { + const parent = control.parent; + if ( + parent?.get('newPassword')?.value === + parent?.get('newPasswordRepeat')?.value + ) { + parent?.get('newPasswordRepeat')?.setErrors(null); + } else { + // latest angular just won't detect the error without setTimeout + setTimeout(() => { + parent + ?.get('newPasswordRepeat') + ?.setErrors({ passwordsDontMatch: control.value }); + }); + } + return null; + }; + + MIN_MAX_LENGTH = 'Passwords must be between 6 and 16 alphanumeric characters'; + + errorTextMap: errorFriendlyText = { + required: "This field can't be empty", + maxlength: this.MIN_MAX_LENGTH, + minlength: this.MIN_MAX_LENGTH, + passwordsDontMatch: "Passwords don't match", + }; + + displayOldPasswordField = input(false); + formGroup = input(); + @Output() submitResults = new EventEmitter(); + + hasError(controlName: string) { + const maybeErrors = this.formGroup()!.get(controlName)?.errors; + const maybeError = + maybeErrors && (Object.keys(maybeErrors)[0] as errorCode); + if (maybeError) { + return this.errorTextMap[maybeError]; + } + return ''; + } + + save() { + const results: PasswordResults = this.formGroup()!.value; + if (this.displayOldPasswordField() && !results.oldPassword) return; + if (!results.newPassword || !results.newPasswordRepeat) return; + this.submitResults.emit(results); + } +} diff --git a/console-webapp/src/app/shared/components/passwordReset/passwordResetVerify.component.html b/console-webapp/src/app/shared/components/passwordReset/passwordResetVerify.component.html new file mode 100644 index 000000000..fa11d02e5 --- /dev/null +++ b/console-webapp/src/app/shared/components/passwordReset/passwordResetVerify.component.html @@ -0,0 +1,27 @@ +

+ +

+@if (isLoading) { +
+ +
+} @else if (errorMessage) { +

Failure

+
+
+ An error occurred: {{ errorMessage }}.

Please double-check the + verification code and try again. +
+
+} @else { +
+

{{ type }} password reset

+ +
+} diff --git a/console-webapp/src/app/shared/components/passwordReset/passwordResetVerify.component.ts b/console-webapp/src/app/shared/components/passwordReset/passwordResetVerify.component.ts new file mode 100644 index 000000000..bce4983f9 --- /dev/null +++ b/console-webapp/src/app/shared/components/passwordReset/passwordResetVerify.component.ts @@ -0,0 +1,105 @@ +// Copyright 2025 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { ActivatedRoute, ParamMap, Router } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { take } from 'rxjs'; +import { RegistrarService } from 'src/app/registrar/registrar.service'; +import { BackendService } from '../../services/backend.service'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { + PasswordInputForm, + PasswordResults, +} from './passwordInputForm.component'; +import EppPasswordEditComponent from 'src/app/settings/security/eppPasswordEdit.component'; + +export interface PasswordResetVerifyResponse { + registrarId: string; + type: string; +} + +@Component({ + selector: 'app-password-reset-verify', + templateUrl: './passwordResetVerify.component.html', + standalone: false, +}) +export class PasswordResetVerifyComponent { + public static PATH = 'password-reset-verify'; + + REGISTRY_LOCK_PASSWORD_VALIDATORS = [ + Validators.required, + PasswordInputForm.newPasswordsMatch, + ]; + + isLoading = true; + type?: string; + errorMessage?: string; + requestVerificationCode = ''; + + passwordUpdateForm: FormGroup | null = null; + + constructor( + protected backendService: BackendService, + protected registrarService: RegistrarService, + private route: ActivatedRoute, + private router: Router + ) {} + + ngOnInit() { + this.route.queryParamMap.pipe(take(1)).subscribe((params: ParamMap) => { + this.requestVerificationCode = + params.get('resetRequestVerificationCode') || ''; + this.backendService + .getPasswordResetInformation(this.requestVerificationCode) + .subscribe({ + error: (err: HttpErrorResponse) => { + this.isLoading = false; + this.errorMessage = err.error; + }, + next: this.presentData.bind(this), + }); + }); + } + + presentData(verificationResponse: PasswordResetVerifyResponse) { + this.type = verificationResponse.type === 'EPP' ? 'EPP' : 'Registry lock'; + this.registrarService.registrarId.set(verificationResponse.registrarId); + const validators = + verificationResponse.type === 'EPP' + ? EppPasswordEditComponent.EPP_VALIDATORS + : this.REGISTRY_LOCK_PASSWORD_VALIDATORS; + + this.passwordUpdateForm = new FormGroup({ + newPassword: new FormControl('', validators), + newPasswordRepeat: new FormControl('', validators), + }); + this.isLoading = false; + } + + save(passwordResults: PasswordResults) { + this.backendService + .finalizePasswordReset( + this.requestVerificationCode, + passwordResults.newPassword + ) + .subscribe({ + error: (err: HttpErrorResponse) => { + this.isLoading = false; + this.errorMessage = err.error; + }, + next: (_) => this.router.navigate(['']), + }); + } +} diff --git a/console-webapp/src/app/shared/services/backend.service.ts b/console-webapp/src/app/shared/services/backend.service.ts index 99247b377..55d1bad71 100644 --- a/console-webapp/src/app/shared/services/backend.service.ts +++ b/console-webapp/src/app/shared/services/backend.service.ts @@ -30,6 +30,7 @@ import { import { Contact } from '../../settings/contact/contact.service'; import { EppPasswordBackendModel } from '../../settings/security/security.service'; import { UserData } from './userData.service'; +import { PasswordResetVerifyResponse } from '../components/passwordReset/passwordResetVerify.component'; @Injectable() export class BackendService { @@ -298,4 +299,19 @@ export class BackendService { registrarId, }); } + + getPasswordResetInformation( + verificationCode: string + ): Observable { + return this.http.get( + `/console-api/password-reset-verify?resetRequestVerificationCode=${verificationCode}` + ); + } + + finalizePasswordReset(verificationCode: string, newPassword: string) { + return this.http.post( + `/console-api/password-reset-verify?resetRequestVerificationCode=${verificationCode}`, + newPassword + ); + } } diff --git a/console-webapp/src/app/users/users.component.ts b/console-webapp/src/app/users/users.component.ts index 21c0d4f2f..c1ff62bd9 100644 --- a/console-webapp/src/app/users/users.component.ts +++ b/console-webapp/src/app/users/users.component.ts @@ -22,7 +22,6 @@ import { RegistrarService } from '../registrar/registrar.service'; import { SnackBarModule } from '../snackbar.module'; import { UserDetailsComponent } from './userDetails.component'; import { User, UsersService } from './users.service'; -import { UserDataService } from '../shared/services/userData.service'; import { FormsModule } from '@angular/forms'; import { UsersListComponent } from './usersList.component'; import { MatSelectChange } from '@angular/material/select'; @@ -55,7 +54,6 @@ export class UsersComponent { constructor( protected registrarService: RegistrarService, protected usersService: UsersService, - private userDataService: UserDataService, private _snackBar: MatSnackBar ) { effect(() => { diff --git a/core/src/main/java/google/registry/ui/server/console/PasswordResetVerifyAction.java b/core/src/main/java/google/registry/ui/server/console/PasswordResetVerifyAction.java index 1c5da6284..32b21c1bd 100644 --- a/core/src/main/java/google/registry/ui/server/console/PasswordResetVerifyAction.java +++ b/core/src/main/java/google/registry/ui/server/console/PasswordResetVerifyAction.java @@ -63,6 +63,7 @@ public class PasswordResetVerifyAction extends ConsoleApiAction { // Temporary flag when testing email sending etc if (!user.getUserRoles().isAdmin()) { setFailedResponse("", HttpServletResponse.SC_FORBIDDEN); + return; } PasswordResetRequest request = tm().transact(() -> loadAndValidateResetRequest(user)); ImmutableMap result = @@ -76,6 +77,7 @@ public class PasswordResetVerifyAction extends ConsoleApiAction { // Temporary flag when testing email sending etc if (!user.getUserRoles().isAdmin()) { setFailedResponse("", HttpServletResponse.SC_FORBIDDEN); + return; } checkArgument(!Strings.isNullOrEmpty(newPassword.orElse(null)), "Password must be provided"); tm().transact(