Add email verification functionality, including backend support, email handling, and user interface integration.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user