Improve email verification flow by adding verified email pre-fill, validation handling, and dialog-based verification support.

This commit is contained in:
akastijn 2025-08-23 22:32:44 +02:00
parent 641083732d
commit 4ccce7e190
5 changed files with 73 additions and 37 deletions

View File

@ -73,34 +73,24 @@ public class AppealController implements AppealsApi {
log.debug("Retrieving mail by uuid and address"); log.debug("Retrieving mail by uuid and address");
EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class) EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class)
.findByUserAndEmail(appeal.uuid(), appeal.email()); .findByUserAndEmail(appeal.uuid(), appeal.email().toLowerCase());
emailVerificationCompletableFuture.complete(Optional.ofNullable(verifiedMail)); emailVerificationCompletableFuture.complete(Optional.ofNullable(verifiedMail));
}); });
Optional<EmailVerification> optionalEmailVerification = emailVerificationCompletableFuture.join(); Optional<EmailVerification> optionalEmailVerification = emailVerificationCompletableFuture.join();
if (optionalEmailVerification.isEmpty()) { if (optionalEmailVerification.isEmpty()) {
return ResponseEntity.ok().body(new AppealResponseDto( return ResponseEntity.badRequest().build();
appeal.id().toString(),
"Your appeal has been saved and a verification mail has been send, please verify your email " +
"address by clicking the link in your email. Once it is verified we will review your appeal.",
false));
} }
EmailVerification emailVerification = optionalEmailVerification.get(); EmailVerification emailVerification = optionalEmailVerification.get();
if (!emailVerification.verified()) { if (!emailVerification.verified()) {
return ResponseEntity.ok().body(new AppealResponseDto( return ResponseEntity.badRequest().build();
appeal.id().toString(), }
"Your appeal has been saved and a verification mail has been resend, please verify your email " +
"address by clicking the link in your email. Once it is verified we will review your appeal.",
false
));
} else {
AppealResponseDto appealResponseDto = new AppealResponseDto( AppealResponseDto appealResponseDto = new AppealResponseDto(
appeal.id().toString(), appeal.id().toString(),
"Your appeal has been submitted. You will be notified when it has been reviewed.", "Your appeal has been submitted. You will be notified when it has been reviewed.",
true); true);
return ResponseEntity.ok().body(appealResponseDto); return ResponseEntity.ok().body(appealResponseDto);
} }
}
@Override @Override
public ResponseEntity<AppealResponseDto> updateMail(UpdateMailDto updateMailDto) { public ResponseEntity<AppealResponseDto> updateMail(UpdateMailDto updateMailDto) {

View File

@ -42,19 +42,20 @@ public class MailVerificationService {
public EmailVerification submitEmail(UUID userUuid, String email) { public EmailVerification submitEmail(UUID userUuid, String email) {
String code = generateCode(); String code = generateCode();
Instant now = Instant.now(); Instant now = Instant.now();
final String finalEmail = email.toLowerCase();
CompletableFuture<EmailVerification> future = new CompletableFuture<>(); CompletableFuture<EmailVerification> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT) Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> { .runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class); EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification existing = mapper.findByUserAndEmail(userUuid, email); EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
EmailVerification toPersist; EmailVerification toPersist;
if (existing == null) { if (existing == null) {
toPersist = new EmailVerification(UUID.randomUUID(), userUuid, email, code, false, now, null, now); toPersist = new EmailVerification(UUID.randomUUID(), userUuid, finalEmail, code, false, now, null, now);
mapper.insert(toPersist); mapper.insert(toPersist);
} else { } else {
mapper.updateCodeAndLastSent(existing.id(), code, now); mapper.updateCodeAndLastSent(existing.id(), code, now);
toPersist = new EmailVerification(existing.id(), userUuid, email, code, false, existing.createdAt(), null, now); toPersist = new EmailVerification(existing.id(), userUuid, finalEmail, code, false, existing.createdAt(), null, now);
} }
future.complete(toPersist); future.complete(toPersist);
}); });
@ -82,14 +83,16 @@ public class MailVerificationService {
public EmailVerification resend(UUID userUuid, String email) { public EmailVerification resend(UUID userUuid, String email) {
String code = generateCode(); String code = generateCode();
Instant now = Instant.now(); Instant now = Instant.now();
final String finalEmail = email.toLowerCase();
CompletableFuture<EmailVerification> future = new CompletableFuture<>(); CompletableFuture<EmailVerification> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT) Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> { .runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class); EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification existing = mapper.findByUserAndEmail(userUuid, email); EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
if (existing != null) { if (existing != null) {
mapper.updateCodeAndLastSent(existing.id(), code, now); mapper.updateCodeAndLastSent(existing.id(), code, now);
future.complete(new EmailVerification(existing.id(), userUuid, email, code, false, existing.createdAt(), null, now)); future.complete(new EmailVerification(existing.id(), userUuid,
finalEmail, code, false, existing.createdAt(), null, now));
} else { } else {
future.complete(null); future.complete(null);
} }
@ -102,13 +105,14 @@ public class MailVerificationService {
} }
public boolean delete(UUID userUuid, String email) { public boolean delete(UUID userUuid, String email) {
final String finalEmail = email.toLowerCase();
CompletableFuture<Boolean> future = new CompletableFuture<>(); CompletableFuture<Boolean> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT) Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> { .runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class); EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification existing = mapper.findByUserAndEmail(userUuid, email); EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
if (existing != null) { if (existing != null) {
mapper.deleteByUserAndEmail(userUuid, email); mapper.deleteByUserAndEmail(userUuid, finalEmail);
future.complete(true); future.complete(true);
} else { } else {
future.complete(false); future.complete(false);
@ -122,7 +126,7 @@ public class MailVerificationService {
MimeMessage message = mailSender.createMimeMessage(); MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true); MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(fromEmail); helper.setFrom(fromEmail);
helper.setTo(emailVerification.email()); helper.setTo(emailVerification.email().toLowerCase());
helper.setSubject("Your verification code"); helper.setSubject("Your verification code");
helper.setText("Your verification code is: " + emailVerification.verificationCode(), false); helper.setText("Your verification code is: " + emailVerification.verificationCode(), false);
mailSender.send(message); mailSender.send(message);

View File

@ -75,10 +75,14 @@
<section class="formPage"> <section class="formPage">
<div class="description"> <div class="description">
<h2>Please enter your email.</h2> <h2>Please enter your email.</h2>
<p style="font-style: italic">It does not have to be your minecraft email.</p> <p style="font-style: italic">It does not have to be your minecraft email. You will have to verify
it</p>
<mat-form-field appearance="fill" style="width: 100%;"> <mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Email</mat-label> <mat-label>Email</mat-label>
<input matInput formControlName="email" placeholder="Email"> <input matInput
formControlName="email"
placeholder="Email"
[defaultValue]="verifiedEmails().length > 0 ? verifiedEmails()[0] : ''">
@if (form.controls.email.invalid && form.controls.email.touched) { @if (form.controls.email.invalid && form.controls.email.touched) {
<mat-error> <mat-error>
@if (form.controls.email.errors?.['required']) { @if (form.controls.email.errors?.['required']) {
@ -90,7 +94,7 @@
} }
</mat-form-field> </mat-form-field>
</div> </div>
<button mat-raised-button (click)="nextPage()" [disabled]="form.controls.email.invalid"> <button mat-raised-button (click)="validateMailOrNextPage()" [disabled]="form.controls.email.invalid">
Next Next
</button> </button>
</section> </section>

View File

@ -21,6 +21,8 @@ import {MatFormFieldModule} from '@angular/material/form-field';
import {MatSelectModule} from '@angular/material/select'; import {MatSelectModule} from '@angular/material/select';
import {MatInputModule} from '@angular/material/input'; import {MatInputModule} from '@angular/material/input';
import {HistoryFormatService} from '@pages/reference/bans/history-format.service'; import {HistoryFormatService} from '@pages/reference/bans/history-format.service';
import {MatDialog} from '@angular/material/dialog';
import {SentComponent} from '@pages/forms/sent/sent.component';
@Component({ @Component({
selector: 'app-appeal', selector: 'app-appeal',
@ -52,7 +54,11 @@ export class AppealComponent implements OnInit, OnDestroy, AfterViewInit {
protected history = signal<PunishmentHistory[] | null>(null); protected history = signal<PunishmentHistory[] | null>(null);
protected selectedPunishment = signal<PunishmentHistory | null>(null); protected selectedPunishment = signal<PunishmentHistory | null>(null);
private emails = signal<EmailEntry[]>([]); private emails = signal<EmailEntry[]>([]);
protected verifiedEmails = computed(() => this.emails().filter(email => email.verified)); protected verifiedEmails = computed(() => this.emails()
.filter(email => email.verified)
.map(email => email.email.toLowerCase()));
protected validatedMail = signal<boolean>(false);
protected dialog = inject(MatDialog);
constructor( constructor(
private elementRef: ElementRef, private elementRef: ElementRef,
@ -64,7 +70,12 @@ export class AppealComponent implements OnInit, OnDestroy, AfterViewInit {
}); });
this.mailService.getUserEmails().subscribe(emails => { this.mailService.getUserEmails().subscribe(emails => {
this.emails.set(emails); this.emails.set(emails);
}) });
this.form.valueChanges.subscribe(() => {
if (this.form.getRawValue().email.toLowerCase() in this.verifiedEmails()) {
this.validatedMail.set(true);
}
});
} }
ngOnInit() { ngOnInit() {
@ -202,6 +213,21 @@ export class AppealComponent implements OnInit, OnDestroy, AfterViewInit {
onPunishmentSelected($event: PunishmentHistory) { onPunishmentSelected($event: PunishmentHistory) {
this.selectedPunishment.set($event); this.selectedPunishment.set($event);
} }
protected validateMailOrNextPage() {
if (this.validatedMail()) {
this.nextPage();
return;
}
const dialogRef = this.dialog.open(SentComponent, {
data: {email: this.form.getRawValue().email},
});
dialogRef.afterClosed().subscribe(result => {
if (result === true) {
this.validatedMail.set(true);
}
});
}
} }
interface Appeal { interface Appeal {

View File

@ -1,11 +1,17 @@
import {Component, inject, Input, input, signal} from '@angular/core'; import {Component, Inject, inject, input, signal} from '@angular/core';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms'; import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms';
import {MatInput, MatLabel} from '@angular/material/input'; import {MatInput, MatLabel} from '@angular/material/input';
import {MatFormFieldModule} from '@angular/material/form-field'; import {MatFormFieldModule} from '@angular/material/form-field';
import {MailService, SubmitEmail, VerifyCode} from '@api'; import {MailService, SubmitEmail, VerifyCode} from '@api';
import {AuthService} from '@services/auth.service'; import {AuthService} from '@services/auth.service';
import {MatButtonModule} from '@angular/material/button'; import {MatButtonModule} from '@angular/material/button';
import {MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle} from '@angular/material/dialog'; import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from '@angular/material/dialog';
import {interval, Subscription} from 'rxjs'; import {interval, Subscription} from 'rxjs';
@Component({ @Component({
@ -38,10 +44,11 @@ export class SentComponent {
private mailService = inject(MailService); private mailService = inject(MailService);
private authService = inject(AuthService); private authService = inject(AuthService);
protected readonly email: string;
constructor( constructor(
public dialogRef: MatDialogRef<SentComponent>, public dialogRef: MatDialogRef<SentComponent>,
@Input() public email: string @Inject(MAT_DIALOG_DATA) public data: InputMail
) { ) {
this.form = new FormGroup({ this.form = new FormGroup({
code: new FormControl('', { code: new FormControl('', {
@ -49,6 +56,8 @@ export class SentComponent {
validators: [Validators.required, Validators.minLength(6), Validators.maxLength(6)] validators: [Validators.required, Validators.minLength(6), Validators.maxLength(6)]
}) })
}); });
this.email = data.email;
this.mailService.submitEmailForVerification({email: this.email.toLowerCase()}).subscribe();
} }
public onSubmit() { public onSubmit() {
@ -81,7 +90,6 @@ export class SentComponent {
this.mailService.resendVerificationEmail(submitEmail).subscribe({ this.mailService.resendVerificationEmail(submitEmail).subscribe({
next: (response) => { next: (response) => {
// Start cooldown timer
this.startResendCooldown(); this.startResendCooldown();
}, },
error: (error) => { error: (error) => {
@ -147,3 +155,7 @@ interface VerifyMailData {
verified: boolean; verified: boolean;
mail: string; mail: string;
} }
interface InputMail {
email: string;
}