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

This commit is contained in:
akastijn 2025-08-23 21:46:10 +02:00
parent da17cf9696
commit 641083732d
26 changed files with 878 additions and 57 deletions

View File

@ -27,6 +27,7 @@ dependencies {
implementation(project(":open_api"))
implementation(project(":database"))
implementation(project(":frontend"))
implementation(project(":discord"))
annotationProcessor("org.projectlombok:lombok")
implementation("com.mysql:mysql-connector-j:8.0.32")
implementation("org.mybatis:mybatis:3.5.13")

View File

@ -11,6 +11,7 @@ import com.nimbusds.jose.proc.SecurityContext;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@ -39,15 +40,17 @@ public class SecurityConfig {
return http
.authorizeHttpRequests(
auth -> auth
.requestMatchers("/api/form/**").hasAuthority(PermissionClaimDto.USER.getValue())
.requestMatchers("/api/login/userLogin").hasAuthority(PermissionClaimDto.USER.getValue())
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/form/**").authenticated()
.requestMatchers("/api/login/getUsername").authenticated()
.requestMatchers("/api/mail/**").authenticated()
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/login/userLogin/**").permitAll()
.anyRequest().permitAll()
)
.csrf(AbstractHttpConfigurer::disable)
.anonymous(AbstractHttpConfigurer::disable)
.oauth2ResourceServer(
oauth2 -> oauth2
.jwt(Customizer.withDefaults())

View File

@ -7,6 +7,8 @@ import com.alttd.altitudeweb.database.litebans.HistoryType;
import com.alttd.altitudeweb.database.litebans.IdHistoryMapper;
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.mappers.AppealDataMapper;
import com.alttd.altitudeweb.model.AppealResponseDto;
import com.alttd.altitudeweb.model.DiscordAppealDto;
@ -22,6 +24,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@ -64,14 +67,39 @@ public class AppealController implements AppealsApi {
throw new ResponseStatusException(HttpStatusCode.valueOf(404), "History not found");
}
appealMail.sendAppealNotification(appeal, history);
CompletableFuture<Optional<EmailVerification>> emailVerificationCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Retrieving mail by uuid and address");
AppealResponseDto appealResponseDto = new AppealResponseDto(
appeal.id().toString(),
"Your appeal has been submitted. You will be notified when it has been reviewed.",
false);
EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class)
.findByUserAndEmail(appeal.uuid(), appeal.email());
emailVerificationCompletableFuture.complete(Optional.ofNullable(verifiedMail));
});
Optional<EmailVerification> optionalEmailVerification = emailVerificationCompletableFuture.join();
return ResponseEntity.ok().body(appealResponseDto);
if (optionalEmailVerification.isEmpty()) {
return ResponseEntity.ok().body(new AppealResponseDto(
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();
if (!emailVerification.verified()) {
return ResponseEntity.ok().body(new AppealResponseDto(
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);
}
}
@Override

View File

@ -0,0 +1,95 @@
package com.alttd.altitudeweb.controllers.forms;
import com.alttd.altitudeweb.api.MailApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.model.MailResponseDto;
import com.alttd.altitudeweb.model.SubmitEmailDto;
import com.alttd.altitudeweb.model.VerifyCodeDto;
import com.alttd.altitudeweb.model.EmailEntryDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.mail.MailVerificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@RequiredArgsConstructor
@RateLimit(limit = 60, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class MailController implements MailApi {
private final MailVerificationService mailVerificationService;
@Override
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailSubmit")
public ResponseEntity<MailResponseDto> submitEmailForVerification(SubmitEmailDto submitEmailDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
EmailVerification saved = mailVerificationService.submitEmail(uuid, submitEmailDto.getEmail());
MailResponseDto response = new MailResponseDto()
.email(saved.email())
.message("Verification email sent")
.verified(false);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@Override
@RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify")
public ResponseEntity<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
Optional<EmailVerification> optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode());
if (optionalEmailVerification.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code");
}
EmailVerification emailVerification = optionalEmailVerification.get();
MailResponseDto response = new MailResponseDto()
.email(emailVerification.email())
.message("Email verified successfully")
.verified(true);
return ResponseEntity.ok(response);
}
@Override
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend")
public ResponseEntity<MailResponseDto> resendVerificationEmail(SubmitEmailDto submitEmailDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
EmailVerification updated = mailVerificationService.resend(uuid, submitEmailDto.getEmail());
if (updated == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
}
MailResponseDto response = new MailResponseDto()
.email(updated.email())
.message("Verification email resent")
.verified(false);
return ResponseEntity.ok(response);
}
@Override
@RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete")
public ResponseEntity<Void> deleteEmail(SubmitEmailDto submitEmailDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail());
if (!deleted) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
}
return ResponseEntity.noContent().build();
}
@Override
public ResponseEntity<List<EmailEntryDto>> getUserEmails() {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
List<EmailVerification> emails = mailVerificationService.listAll(uuid);
List<EmailEntryDto> result = emails.stream()
.map(ev -> new EmailEntryDto().email(ev.email()).verified(ev.verified()))
.toList();
return ResponseEntity.ok(result);
}
}

View File

@ -134,20 +134,32 @@ public class LoginController implements LoginApi {
return username.join();
}
@Value("${UNSECURED:#{false}}")
private boolean unsecured;
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login")
@Override
public ResponseEntity<String> login(String code) {
if (unsecured) {
log.warn("Unsecured login is enabled, skipping login validation!");
} else {
log.info("Received login request with code {}", code);
}
if (code == null) {
log.warn("Received null login code");
return ResponseEntity.badRequest().build();
}
CacheEntry cacheEntry = cache.get(code);
if (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now())) {
if (!unsecured && (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now()))) {
log.warn("Received invalid login code {}", code);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
if (unsecured && cacheEntry == null) {
cacheEntry = new CacheEntry(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"), Instant.now().plusSeconds(TimeUnit.DAYS.toSeconds(1)));
}
String token = generateToken(cacheEntry.uuid);
log.debug("Generated token for user {} with token {}", cacheEntry.uuid, token);

View File

@ -4,6 +4,7 @@ import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import com.alttd.altitudeweb.model.MinecraftAppealDto;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.UUID;
@Service
@ -21,9 +22,11 @@ public class AppealDataMapper {
return new Appeal(
UUID.randomUUID(),
minecraftAppealDto.getUuid(),
minecraftAppealDto.getPunishmentType().toString(),
minecraftAppealDto.getPunishmentId(),
minecraftAppealDto.getUsername(),
minecraftAppealDto.getAppeal(),
null,
Instant.now(),
null,
minecraftAppealDto.getEmail(),
null

View File

@ -0,0 +1,140 @@
package com.alttd.altitudeweb.services.mail;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.setup.Connection;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Optional;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
@RequiredArgsConstructor
public class MailVerificationService {
private final JavaMailSender mailSender;
@Value("${spring.mail.username}")
private String fromEmail;
public java.util.List<EmailVerification> listAll(UUID userUuid) {
java.util.concurrent.CompletableFuture<java.util.List<EmailVerification>> future = new java.util.concurrent.CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
future.complete(mapper.findAllByUser(userUuid));
});
return future.join();
}
public EmailVerification submitEmail(UUID userUuid, String email) {
String code = generateCode();
Instant now = Instant.now();
CompletableFuture<EmailVerification> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification existing = mapper.findByUserAndEmail(userUuid, email);
EmailVerification toPersist;
if (existing == null) {
toPersist = new EmailVerification(UUID.randomUUID(), userUuid, email, code, false, now, null, now);
mapper.insert(toPersist);
} else {
mapper.updateCodeAndLastSent(existing.id(), code, now);
toPersist = new EmailVerification(existing.id(), userUuid, email, code, false, existing.createdAt(), null, now);
}
future.complete(toPersist);
});
EmailVerification saved = future.join();
sendVerificationEmail(saved);
return saved;
}
public Optional<EmailVerification> verifyCode(UUID userUuid, String code) {
CompletableFuture<Optional<EmailVerification>> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification found = mapper.findByUserAndCode(userUuid, code);
if (found == null) {
future.complete(Optional.empty());
return;
}
mapper.markVerified(found.id(), Instant.now());
future.complete(Optional.of(found));
});
return future.join();
}
public EmailVerification resend(UUID userUuid, String email) {
String code = generateCode();
Instant now = Instant.now();
CompletableFuture<EmailVerification> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification existing = mapper.findByUserAndEmail(userUuid, email);
if (existing != null) {
mapper.updateCodeAndLastSent(existing.id(), code, now);
future.complete(new EmailVerification(existing.id(), userUuid, email, code, false, existing.createdAt(), null, now));
} else {
future.complete(null);
}
});
EmailVerification updated = future.join();
if (updated != null) {
sendVerificationEmail(updated);
}
return updated;
}
public boolean delete(UUID userUuid, String email) {
CompletableFuture<Boolean> future = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sql -> {
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
EmailVerification existing = mapper.findByUserAndEmail(userUuid, email);
if (existing != null) {
mapper.deleteByUserAndEmail(userUuid, email);
future.complete(true);
} else {
future.complete(false);
}
});
return future.join();
}
private void sendVerificationEmail(EmailVerification emailVerification) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(fromEmail);
helper.setTo(emailVerification.email());
helper.setSubject("Your verification code");
helper.setText("Your verification code is: " + emailVerification.verificationCode(), false);
mailSender.send(message);
} catch (MessagingException e) {
log.error("Failed to send verification email to {}", emailVerification.email(), e);
}
}
private String generateCode() {
// 6-digit numeric code
Random random = new Random();
int num = 100000 + random.nextInt(899999);
return String.valueOf(num);
}
}

View File

@ -1,9 +1,4 @@
spring.application.name=AltitudeWeb
database.name=${DB_NAME:web_db}
database.port=${DB_PORT:3306}
database.host=${DB_HOST:localhost}
database.user=${DB_USER:root}
database.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:http://localhost:4200}
my-server.address=${SERVER_ADDRESS:http://localhost}
cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080}
my-server.address=${SERVER_ADDRESS:http://localhost:8080}
logging.level.com.alttd.altitudeweb=DEBUG
logging.level.org.springframework.security=DEBUG

View File

@ -6,7 +6,8 @@ import lombok.Getter;
public enum Databases {
DEFAULT("web_db"),
LUCK_PERMS("luckperms"),
LITE_BANS("litebans");
LITE_BANS("litebans"),
DISCORD("discordLink");
private final String internalName;

View File

@ -0,0 +1,4 @@
package com.alttd.altitudeweb.database.discord;
public record OutputChannel(long guild, String outputType, long channel, String channelType) {
}

View File

@ -0,0 +1,15 @@
package com.alttd.altitudeweb.database.discord;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
public interface OutputChannelMapper {
@Select("""
SELECT guild, output_type, channel, channel_type
FROM output_channels
WHERE output_type = #{outputType}
""")
List<OutputChannel> getChannelsWithOutputType(@Param("outputType") String outputType);
}

View File

@ -11,7 +11,7 @@ import java.util.UUID;
@NoArgsConstructor
@AllArgsConstructor
public class PrivilegedUser {
private int id;
private Integer id;
private UUID uuid;
private List<String> permissions;
}

View File

@ -0,0 +1,16 @@
package com.alttd.altitudeweb.database.web_db.mail;
import java.time.Instant;
import java.util.UUID;
public record EmailVerification(
UUID id,
UUID userUuid,
String email,
String verificationCode,
boolean verified,
Instant createdAt,
Instant verifiedAt,
Instant lastSentAt
) {
}

View File

@ -0,0 +1,58 @@
package com.alttd.altitudeweb.database.web_db.mail;
import org.apache.ibatis.annotations.*;
import java.time.Instant;
import java.util.UUID;
public interface EmailVerificationMapper {
@Insert("""
INSERT INTO user_emails (id, user_uuid, email, verification_code, verified, created_at, last_sent_at)
VALUES (#{id}, #{userUuid}, #{email}, #{verificationCode}, #{verified}, #{createdAt}, #{lastSentAt})
""")
void insert(EmailVerification emailVerification);
@Select("""
SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified,
created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt
FROM user_emails
WHERE user_uuid = #{userUuid} AND email = #{email}
""")
EmailVerification findByUserAndEmail(@Param("userUuid") UUID userUuid, @Param("email") String email);
@Select("""
SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified,
created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt
FROM user_emails
WHERE user_uuid = #{userUuid} AND verification_code = #{code}
ORDER BY created_at DESC LIMIT 1
""")
EmailVerification findByUserAndCode(@Param("userUuid") UUID userUuid, @Param("code") String code);
@Select("""
SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified,
created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt
FROM user_emails
WHERE user_uuid = #{userUuid}
ORDER BY created_at ASC
""")
java.util.List<EmailVerification> findAllByUser(@Param("userUuid") UUID userUuid);
@Update("""
UPDATE user_emails SET verified = 1, verified_at = #{verifiedAt}
WHERE id = #{id}
""")
void markVerified(@Param("id") UUID id, @Param("verifiedAt") Instant verifiedAt);
@Update("""
UPDATE user_emails SET verification_code = #{code}, last_sent_at = #{lastSentAt}, verified = 0, verified_at = NULL
WHERE id = #{id}
""")
void updateCodeAndLastSent(@Param("id") UUID id, @Param("code") String code, @Param("lastSentAt") Instant lastSentAt);
@Delete("""
DELETE FROM user_emails WHERE user_uuid = #{userUuid} AND email = #{email}
""")
void deleteByUserAndEmail(@Param("userUuid") UUID userUuid, @Param("email") String email);
}

View File

@ -35,6 +35,7 @@ public class Connection {
InitializeWebDb.init();
InitializeLiteBans.init();
InitializeLuckPerms.init();
InitializeDiscord.init();
}
@FunctionalInterface

View File

@ -0,0 +1,19 @@
package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class InitializeDiscord {
protected static void init() {
log.info("Initializing Discord");
Connection.getConnection(Databases.DISCORD, (configuration) -> {
configuration.addMapper(OutputChannelMapper.class);
}).join();
log.debug("Initialized Discord");
}
}

View File

@ -22,6 +22,7 @@ public class InitializeWebDb {
configuration.addMapper(KeyPairMapper.class);
configuration.addMapper(PrivilegedUserMapper.class);
configuration.addMapper(AppealMapper.class);
configuration.addMapper(com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper.class);
}).join()
.runQuery(sqlSession -> {
createSettingsTable(sqlSession);
@ -29,6 +30,7 @@ public class InitializeWebDb {
createPrivilegedUsersTable(sqlSession);
createPrivilegesTable(sqlSession);
createAppealTable(sqlSession);
createUserEmailsTable(sqlSession);
});
log.debug("Initialized WebDb");
}
@ -102,6 +104,27 @@ public class InitializeWebDb {
}
}
private static void createUserEmailsTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS user_emails (
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
user_uuid UUID NOT NULL,
email VARCHAR(255) NOT NULL,
verification_code VARCHAR(16) NOT NULL,
verified BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
verified_at TIMESTAMP NULL,
last_sent_at TIMESTAMP NULL,
FOREIGN KEY (user_uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private static void createAppealTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS appeals (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();

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

View File

@ -26,6 +26,8 @@ tags:
description: All actions shared between forms
- name: appeals
description: All action related to appeals
- name: mail
description: All actions related to user email verification
paths:
/api/team/{team}:
$ref: './schemas/team/team.yml#/getTeam'
@ -67,3 +69,13 @@ paths:
$ref: './schemas/particles/particles.yml#/DownloadFile'
/api/files/download/{uuid}/{filename}:
$ref: './schemas/particles/particles.yml#/DownloadFileForUser'
/api/mail/submit:
$ref: './schemas/forms/mail/mail.yml#/SubmitEmail'
/api/mail/verify:
$ref: './schemas/forms/mail/mail.yml#/VerifyCode'
/api/mail/resend:
$ref: './schemas/forms/mail/mail.yml#/ResendEmail'
/api/mail/delete:
$ref: './schemas/forms/mail/mail.yml#/DeleteEmail'
/api/mail/list:
$ref: './schemas/forms/mail/mail.yml#/GetEmails'

View File

@ -0,0 +1,166 @@
SubmitEmail:
post:
tags:
- mail
summary: Submit an email for verification
description: Store a new email for the authenticated user and send a verification code
operationId: submitEmailForVerification
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SubmitEmail'
responses:
'201':
description: Verification email sent
content:
application/json:
schema:
$ref: '#/components/schemas/MailResponse'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
VerifyCode:
post:
tags:
- mail
summary: Verify an email using a code
description: Verify the email for the authenticated user by providing the received code
operationId: verifyEmailCode
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/VerifyCode'
responses:
'200':
description: Email verified successfully
content:
application/json:
schema:
$ref: '#/components/schemas/MailResponse'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
ResendEmail:
post:
tags:
- mail
summary: Resend verification email
description: Request a new verification email to be sent for a pending email
operationId: resendVerificationEmail
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SubmitEmail'
responses:
'200':
description: Verification email resent
content:
application/json:
schema:
$ref: '#/components/schemas/MailResponse'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
DeleteEmail:
delete:
tags:
- mail
summary: Delete an email
description: Delete an email associated with the authenticated user
operationId: deleteEmail
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SubmitEmail'
responses:
'204':
description: Email deleted
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
GetEmails:
get:
tags:
- mail
summary: Get all emails for the authenticated user
description: Returns both verified and unverified emails for the authenticated user
operationId: getUserEmails
responses:
'200':
description: Emails retrieved successfully
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/EmailEntry'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
components:
schemas:
SubmitEmail:
type: object
required:
- email
properties:
email:
type: string
description: Email address to verify
VerifyCode:
type: object
required:
- code
properties:
code:
type: string
description: Verification code received by email
MailResponse:
type: object
required:
- message
- email
- verified
properties:
email:
type: string
message:
type: string
verified:
type: boolean
EmailEntry:
type: object
required:
- email
- verified
properties:
email:
type: string
description: The user's email address
verified:
type: boolean
description: Whether the email has been verified