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 @@
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 @@
+
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(