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,33 +73,23 @@ 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(
appeal.id().toString(),
"Your appeal has been submitted. You will be notified when it has been reviewed.",
true);
return ResponseEntity.ok().body(appealResponseDto);
} }
AppealResponseDto appealResponseDto = new AppealResponseDto(
appeal.id().toString(),
"Your appeal has been submitted. You will be notified when it has been reviewed.",
true);
return ResponseEntity.ok().body(appealResponseDto);
} }
@Override @Override

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