1
0
mirror of https://github.com/google/nomulus synced 2026-01-03 11:45:39 +00:00

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.
This commit is contained in:
gbrodman
2025-08-18 23:00:44 -04:00
committed by GitHub
parent a61a667992
commit 4738b979e4
13 changed files with 362 additions and 149 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -16,67 +16,11 @@
<p class="secondary-text">
Passwords must be between 6 and 16 alphanumeric characters
</p>
<form
(ngSubmit)="save()"
<password-input-form-component
[displayOldPasswordField]="true"
[formGroup]="passwordUpdateForm"
class="settings-security__edit-password-form"
>
<div class="settings-security__edit-password-field">
<mat-form-field appearance="outline">
<mat-label>Old password: </mat-label>
<input
matInput
type="text"
formControlName="oldPassword"
required
autocomplete="current-password"
/>
<mat-error *ngIf="hasError('oldPassword') as errorText">{{
errorText
}}</mat-error>
</mat-form-field>
</div>
<div class="settings-security__edit-password-field">
<mat-form-field appearance="outline">
<mat-label>New password: </mat-label>
<input
matInput
type="text"
formControlName="newPassword"
required
autocomplete="new-password"
/>
<mat-error *ngIf="hasError('newPassword') as errorText">{{
errorText
}}</mat-error>
</mat-form-field>
</div>
<div class="settings-security__edit-password-field">
<mat-form-field appearance="outline">
<mat-label>Confirm new password: </mat-label>
<input
matInput
type="text"
formControlName="newPasswordRepeat"
required
autocomplete="new-password"
/>
<mat-error *ngIf="hasError('newPasswordRepeat') as errorText">{{
errorText
}}</mat-error>
</mat-form-field>
</div>
<button
mat-flat-button
color="primary"
[disabled]="!passwordUpdateForm.valid"
aria-label="Save epp password update"
type="submit"
class="settings-security__edit-password-save"
>
Save
</button>
</form>
(submitResults)="save($event)"
/>
@if(userDataService.userData()?.isAdmin) {
<div class="settings-security__reset-password-field">
<h2>Need to reset your EPP password?</h2>

View File

@@ -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;
}

View File

@@ -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: () => {

View File

@@ -0,0 +1,63 @@
<form
(ngSubmit)="save()"
[formGroup]="formGroup()!"
class="console-app__password-input-form"
>
@if (displayOldPasswordField()) {
<div class="console-app__password-input-form-field">
<mat-form-field appearance="outline">
<mat-label>Old password: </mat-label>
<input
matInput
type="text"
formControlName="oldPassword"
required
autocomplete="current-password"
/>
<mat-error *ngIf="hasError('oldPassword') as errorText">{{
errorText
}}</mat-error>
</mat-form-field>
</div>
}
<div class="console-app__password-input-form-field">
<mat-form-field appearance="outline">
<mat-label>New password: </mat-label>
<input
matInput
type="text"
formControlName="newPassword"
required
autocomplete="new-password"
/>
<mat-error *ngIf="hasError('newPassword') as errorText">{{
errorText
}}</mat-error>
</mat-form-field>
</div>
<div class="console-app__password-input-form-field">
<mat-form-field appearance="outline">
<mat-label>Confirm new password: </mat-label>
<input
matInput
type="text"
formControlName="newPasswordRepeat"
required
autocomplete="new-password"
/>
<mat-error *ngIf="hasError('newPasswordRepeat') as errorText">{{
errorText
}}</mat-error>
</mat-form-field>
</div>
<button
mat-flat-button
color="primary"
[disabled]="!formGroup()?.valid"
aria-label="Save new password"
type="submit"
class="console-app__password-input-form-save"
>
Save
</button>
</form>

View File

@@ -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;
}
}

View File

@@ -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<boolean>(false);
formGroup = input<FormGroup>();
@Output() submitResults = new EventEmitter<PasswordResults>();
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);
}
}

View File

@@ -0,0 +1,27 @@
<p>
<button mat-icon-button aria-label="Go home" [routerLink]="['']">
<mat-icon>arrow_back</mat-icon>
</button>
</p>
@if (isLoading) {
<div class="console-app__password-reset-verify-spinner">
<mat-spinner />
</div>
} @else if (errorMessage) {
<h1 class="mat-headline-4">Failure</h1>
<div class="console-app__password-reset-content">
<div class="console-app__password-reset-subhead">
An error occurred: {{ errorMessage }}.<br /><br />Please double-check the
verification code and try again.
</div>
</div>
} @else {
<div class="console-app__password-reset-verify">
<h1 class="mat-headline-4">{{ type }} password reset</h1>
<password-input-form-component
[displayOldPasswordField]="false"
[formGroup]="passwordUpdateForm!"
(submitResults)="save($event)"
/>
</div>
}

View File

@@ -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<any> | 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(['']),
});
}
}

View File

@@ -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<PasswordResetVerifyResponse> {
return this.http.get<PasswordResetVerifyResponse>(
`/console-api/password-reset-verify?resetRequestVerificationCode=${verificationCode}`
);
}
finalizePasswordReset(verificationCode: string, newPassword: string) {
return this.http.post(
`/console-api/password-reset-verify?resetRequestVerificationCode=${verificationCode}`,
newPassword
);
}
}

View File

@@ -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(() => {

View File

@@ -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<String, ?> 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(