Add email verification functionality, including backend support, email handling, and user interface integration.

This commit is contained in:
2025-08-23 21:46:10 +02:00
parent da17cf9696
commit 641083732d
26 changed files with 878 additions and 57 deletions
@@ -1,6 +1,16 @@
import {AfterViewInit, Component, ElementRef, OnInit, Renderer2, signal} from '@angular/core';
import {
AfterViewInit,
Component,
computed,
ElementRef,
inject,
OnDestroy,
OnInit,
Renderer2,
signal
} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {AppealsService, HistoryService, MinecraftAppeal, PunishmentHistory} from '@api';
import {AppealsService, EmailEntry, HistoryService, MailService, MinecraftAppeal, PunishmentHistory} from '@api';
import {HeaderComponent} from '@header/header.component';
import {NgOptimizedImage} from '@angular/common';
import {MatButtonModule} from '@angular/material/button';
@@ -28,19 +38,23 @@ import {HistoryFormatService} from '@pages/reference/bans/history-format.service
templateUrl: './appeal.component.html',
styleUrl: './appeal.component.scss'
})
export class AppealComponent implements OnInit, AfterViewInit {
export class AppealComponent implements OnInit, OnDestroy, AfterViewInit {
public form: FormGroup<Appeal>;
private mailService = inject(MailService);
private historyFormatService = inject(HistoryFormatService);
private appealsService = inject(AppealsService);
private historyService = inject(HistoryService);
public authService = inject(AuthService);
private resizeObserver: ResizeObserver | null = null;
private boundHandleResize: any;
protected form: FormGroup<Appeal>;
protected history = signal<PunishmentHistory[] | null>(null);
protected selectedPunishment = signal<PunishmentHistory | null>(null);
private emails = signal<EmailEntry[]>([]);
protected verifiedEmails = computed(() => this.emails().filter(email => email.verified));
constructor(
private historyFormatService: HistoryFormatService,
private appealApi: AppealsService,
private historyApi: HistoryService,
protected authService: AuthService,
private elementRef: ElementRef,
private renderer: Renderer2
) {
@@ -48,6 +62,9 @@ export class AppealComponent implements OnInit, AfterViewInit {
email: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.email]}),
appeal: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.minLength(10)]})
});
this.mailService.getUserEmails().subscribe(emails => {
this.emails.set(emails);
})
}
ngOnInit() {
@@ -55,7 +72,7 @@ export class AppealComponent implements OnInit, AfterViewInit {
if (uuid === null) {
throw new Error('JWT subject is null, are you logged in?');
}
this.historyApi.getAllHistoryForUUID(uuid).subscribe(history => {
this.historyService.getAllHistoryForUUID(uuid).subscribe(history => {
this.history.set(history.filter(item => this.historyFormatService.isActive(item)));
})
}
@@ -149,7 +166,7 @@ export class AppealComponent implements OnInit, AfterViewInit {
username: this.authService.username()!,
uuid: uuid
}
this.appealApi.submitMinecraftAppeal(appeal).subscribe()
this.appealsService.submitMinecraftAppeal(appeal).subscribe()
}
public currentPageIndex: number = 0;
@@ -0,0 +1,48 @@
<h2 mat-dialog-title>Email Verification</h2>
<div mat-dialog-content>
<p>Please enter the 6-character verification code sent to: <strong>{{ email }}</strong></p>
<form [formGroup]="form">
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Verification Code</mat-label>
<input matInput formControlName="code" placeholder="Enter 6-character code">
@if (form.controls.code.invalid && form.controls.code.touched) {
<mat-error>
@if (form.controls.code.errors?.['required']) {
Verification code is required
} @else if (form.controls.code.errors?.['minlength'] || form.controls.code.errors?.['maxlength']) {
Code must be exactly 6 characters
} @else {
Please enter the 6-character code we sent you.
}
</mat-error>
}
</mat-form-field>
</form>
@if (mailVerified()) {
<p class="success-message">Email verified successfully!</p>
}
</div>
<div mat-dialog-actions align="end">
<button mat-button (click)="onCancel()">Cancel</button>
<button mat-button
color="accent"
(click)="onResend()"
[disabled]="resendCooldown()">
@if (resendCooldown()) {
Resend ({{ cooldownSeconds() }}s)
} @else {
Resend Code
}
</button>
<button mat-flat-button
color="primary"
(click)="onSubmit()"
[disabled]="form.invalid">
Submit
</button>
</div>
@@ -0,0 +1,23 @@
:host {
display: block;
}
.mat-dialog-content {
min-height: 120px;
max-width: 400px;
}
.success-message {
color: #4caf50;
font-weight: 500;
margin-top: 16px;
}
mat-form-field {
width: 100%;
margin-bottom: 16px;
}
button {
margin-left: 8px;
}
@@ -0,0 +1,149 @@
import {Component, inject, Input, input, signal} from '@angular/core';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms';
import {MatInput, MatLabel} from '@angular/material/input';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MailService, SubmitEmail, VerifyCode} from '@api';
import {AuthService} from '@services/auth.service';
import {MatButtonModule} from '@angular/material/button';
import {MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle} from '@angular/material/dialog';
import {interval, Subscription} from 'rxjs';
@Component({
selector: 'app-sent',
imports: [
FormsModule,
MatFormFieldModule,
MatInput,
MatLabel,
ReactiveFormsModule,
MatButtonModule,
MatDialogTitle,
MatDialogContent,
MatDialogActions
],
templateUrl: './sent.component.html',
styleUrl: './sent.component.scss'
})
export class SentComponent {
protected form: FormGroup<VerifyMail>;
protected readonly completionMessage = input<string>("Thank you for completing your form!");
protected readonly verifyMail = input<VerifyMailData | null>(null);
protected mailVerified = signal<boolean>(false);
// For resend cooldown
protected resendCooldown = signal<boolean>(false);
protected cooldownSeconds = signal<number>(60);
private cooldownSubscription: Subscription | null = null;
private mailService = inject(MailService);
private authService = inject(AuthService);
constructor(
public dialogRef: MatDialogRef<SentComponent>,
@Input() public email: string
) {
this.form = new FormGroup({
code: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(6), Validators.maxLength(6)]
})
});
}
public onSubmit() {
if (this.form === undefined) {
console.error('Form is undefined');
return;
}
if (this.form.valid) {
this.sendForm();
} else {
Object.keys(this.form.controls).forEach(field => {
const control = this.form!.get(field);
control?.markAsTouched({onlySelf: true});
});
}
}
public onCancel() {
this.dialogRef.close(false);
}
public onResend() {
if (this.resendCooldown()) {
return;
}
const submitEmail: SubmitEmail = {
email: this.email
};
this.mailService.resendVerificationEmail(submitEmail).subscribe({
next: (response) => {
// Start cooldown timer
this.startResendCooldown();
},
error: (error) => {
console.error('Error resending verification email', error);
}
});
}
private startResendCooldown() {
this.resendCooldown.set(true);
this.cooldownSeconds.set(60);
if (this.cooldownSubscription) {
this.cooldownSubscription.unsubscribe();
}
this.cooldownSubscription = interval(1000).subscribe(() => {
const currentSeconds = this.cooldownSeconds();
if (currentSeconds <= 1) {
this.resendCooldown.set(false);
this.cooldownSubscription?.unsubscribe();
this.cooldownSubscription = null;
} else {
this.cooldownSeconds.set(currentSeconds - 1);
}
});
}
private sendForm() {
const rawValue = this.form.getRawValue();
if (this.authService.isAuthenticated$()) {
const form: VerifyCode = {
code: rawValue.code,
};
this.mailService.verifyEmailCode(form).subscribe({
next: (mailResponse) => {
this.mailVerified.set(mailResponse.verified);
if (mailResponse.verified) {
this.dialogRef.close(true);
}
},
error: (error) => {
console.error('Error verifying email code', error);
}
});
} else {
throw new Error('User not logged in');
}
}
ngOnDestroy() {
if (this.cooldownSubscription) {
this.cooldownSubscription.unsubscribe();
}
}
}
interface VerifyMail {
code: FormControl<string>;
}
interface VerifyMailData {
verified: boolean;
mail: string;
}
@@ -62,7 +62,7 @@
<li><a href="https://alttd.com/blog/">Blog</a></li>
</ul>
</li>
@if (!isAuthenticated) {
@if (!isAuthenticated()) {
<li>
<a (click)="openLoginDialog()">
Login
@@ -137,7 +137,7 @@
<li class="nav_li"><a class="nav_link2" target="_blank" href="https://alttd.com/blog/">Blog</a></li>
</ul>
</li>
@if (isAuthenticated) {
@if (isAuthenticated()) {
<li class="nav_li">
<a [id]="getCurrentPageId(['particles'])"
class="nav_link fake_link" [ngClass]="active">Special</a>
@@ -146,7 +146,7 @@
</ul>
</li>
}
@if (!isAuthenticated) {
@if (!isAuthenticated()) {
<li class="nav_li login-button">
<a class="nav_link fake_link" (click)="openLoginDialog()">
Login
@@ -1,4 +1,4 @@
import {Component, HostListener, inject, Input, OnDestroy, OnInit} from '@angular/core';
import {Component, computed, HostListener, inject, Input, OnDestroy, Signal} from '@angular/core';
import {CommonModule, NgOptimizedImage} from '@angular/common';
import {ThemeComponent} from '@shared-components/theme/theme.component';
import {RouterLink} from '@angular/router';
@@ -19,7 +19,7 @@ import {MatDialog} from '@angular/material/dialog';
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent implements OnInit, OnDestroy {
export class HeaderComponent implements OnDestroy {
private authService: AuthService = inject(AuthService)
private dialog: MatDialog = inject(MatDialog)
@@ -32,14 +32,7 @@ export class HeaderComponent implements OnInit, OnDestroy {
public active: string = '';
public inverseYPos: number = 0;
private subscription: Subscription | undefined;
public isAuthenticated: boolean = false;
ngOnInit(): void {
this.subscription = this.authService.isAuthenticated$.subscribe(isAuthenticated => {
this.isAuthenticated = isAuthenticated;
}
);
}
public isAuthenticated: Signal<boolean> = computed(() => this.authService.isAuthenticated$());
ngOnDestroy(): void {
this.subscription?.unsubscribe();
+12 -13
View File
@@ -1,6 +1,6 @@
import {Injectable, signal} from '@angular/core';
import {LoginService} from '@api';
import {BehaviorSubject, Observable, throwError} from 'rxjs';
import {Observable, throwError} from 'rxjs';
import {catchError, tap} from 'rxjs/operators';
import {MatSnackBar} from '@angular/material/snack-bar';
import {JwtHelperService} from '@auth0/angular-jwt';
@@ -10,11 +10,10 @@ import {JwtClaims} from '@custom-types/jwt_interface'
providedIn: 'root'
})
export class AuthService {
private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);
public isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
private isAuthenticatedSubject = signal<boolean>(false);
public readonly isAuthenticated$ = this.isAuthenticatedSubject.asReadonly();
private userClaimsSubject = new BehaviorSubject<JwtClaims | null>(null);
public userClaims$ = this.userClaimsSubject.asObservable();
private userClaimsSubject = signal<JwtClaims | null>(null);
private jwtHelper = new JwtHelperService();
private _username = signal<string | null>(null);
public readonly username = this._username.asReadonly();
@@ -34,7 +33,7 @@ export class AuthService {
return this.loginService.login(code).pipe(
tap(jwt => {
this.saveJwt(jwt);
this.isAuthenticatedSubject.next(true);
this.isAuthenticatedSubject.set(true);
this.reloadUsername();
}),
@@ -61,8 +60,8 @@ export class AuthService {
*/
public logout(): void {
localStorage.removeItem('jwt');
this.isAuthenticatedSubject.next(false);
this.userClaimsSubject.next(null);
this.isAuthenticatedSubject.set(false);
this.userClaimsSubject.set(null);
this._username.set(null);
}
@@ -84,8 +83,8 @@ export class AuthService {
const claims = this.extractJwtClaims(jwt);
console.log("User claims: ", claims);
this.userClaimsSubject.next(claims);
this.isAuthenticatedSubject.next(true);
this.userClaimsSubject.set(claims);
this.isAuthenticatedSubject.set(true);
if (this.username() == null) {
this.reloadUsername();
}
@@ -111,7 +110,7 @@ export class AuthService {
const claims = this.extractJwtClaims(jwt);
console.log("Saving user claims: ", claims);
this.userClaimsSubject.next(claims);
this.userClaimsSubject.set(claims);
}
/**
@@ -125,7 +124,7 @@ export class AuthService {
* Get user authorizations from claims
*/
public getUserAuthorizations(): string[] {
const claims = this.userClaimsSubject.getValue();
const claims = this.userClaimsSubject();
return claims?.authorities || [];
}
@@ -135,7 +134,7 @@ export class AuthService {
}
public getUuid(): string | null {
const jwtClaims = this.userClaimsSubject.getValue();
const jwtClaims = this.userClaimsSubject();
if (jwtClaims === null) {
return null;
}