Refactor AuthenticatedUuid to singleton service and replace static calls across the codebase. Add JWT authority converters, improve punishment expiry handling, and enhance frontend dialog functionality for editing punishments. Extend CORS allowed methods and origins.

This commit is contained in:
akastijn 2025-10-24 21:10:34 +02:00
parent f117cb2477
commit 64ea68ab39
15 changed files with 176 additions and 62 deletions

View File

@ -9,6 +9,7 @@ import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jose.proc.SecurityContext;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
@ -17,16 +18,22 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey; import java.security.interfaces.RSAPublicKey;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
@ -54,7 +61,7 @@ public class SecurityConfig {
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.oauth2ResourceServer( .oauth2ResourceServer(
oauth2 -> oauth2 oauth2 -> oauth2
.jwt(Customizer.withDefaults()) .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
.authenticationEntryPoint(securityAuthFailureHandler) .authenticationEntryPoint(securityAuthFailureHandler)
.accessDeniedHandler(securityAuthFailureHandler) .accessDeniedHandler(securityAuthFailureHandler)
) )
@ -85,4 +92,46 @@ public class SecurityConfig {
KeyPair keyPair = keyPairService.getJwtSigningKeyPair(); KeyPair keyPair = keyPairService.getJwtSigningKeyPair();
return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keyPair.getPublic()).build(); return NimbusJwtDecoder.withPublicKey((RSAPublicKey) keyPair.getPublic()).build();
} }
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> claims = jwt.getClaims();
Object authoritiesClaim = claims.get("authorities");
if (authoritiesClaim instanceof List<?> authorities) {
Collection<GrantedAuthority> authorityList = authorities.stream()
.map(Object::toString)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
log.debug("Authorities found in authorities: {}", authorityList);
return authorityList;
}
Object scopeClaim = claims.get("scope");
if (scopeClaim instanceof String scopeString) {
Collection<GrantedAuthority> authorityList = Arrays.stream(scopeString.split(" "))
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
.collect(Collectors.toList());
log.debug("Authorities found in authorities scope string: {}", authorityList);
return authorityList;
}
if (scopeClaim instanceof List<?> scopeList) {
Collection<GrantedAuthority> authorityList = scopeList.stream()
.map(Object::toString)
.map(scope -> new SimpleGrantedAuthority("SCOPE_" + scope))
.collect(Collectors.toList());
log.debug("Authorities found in authorities scope list: {}", authorityList);
return authorityList;
}
log.debug("No granted authorities found");
return Collections.emptyList();
});
return converter;
}
} }

View File

@ -18,7 +18,7 @@ public class CorsConfig implements WebMvcConfigurer {
log.info("Registering CORS mappings for {}", String.join(", ", allowedOrigins)); log.info("Registering CORS mappings for {}", String.join(", ", allowedOrigins));
registry.addMapping("/**") registry.addMapping("/**")
.allowedOrigins(allowedOrigins) .allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*") .allowedHeaders("*")
.allowCredentials(true); .allowCredentials(true);
} }

View File

@ -1,24 +1,33 @@
package com.alttd.altitudeweb.controllers.data_from_auth; package com.alttd.altitudeweb.controllers.data_from_auth;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.UUID; import java.util.UUID;
@Service
public class AuthenticatedUuid { public class AuthenticatedUuid {
@Value("${UNSECURED:#{false}}")
private boolean unsecured;
/** /**
* Extracts and validates the authenticated user's UUID from the JWT token. * Extracts and validates the authenticated user's UUID from the JWT token.
* *
* @return The UUID of the authenticated user * @return The UUID of the authenticated user
* @throws ResponseStatusException with 401 status if authentication is invalid * @throws ResponseStatusException with 401 status if authentication is invalid
*/ */
public static UUID getAuthenticatedUserUuid() { public UUID getAuthenticatedUserUuid() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) { if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
if (unsecured) {
return UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f");
}
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required"); throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required");
} }

View File

@ -33,6 +33,7 @@ import java.util.concurrent.TimeUnit;
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS) @RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class ApplicationController implements ApplicationsApi { public class ApplicationController implements ApplicationsApi {
private final AuthenticatedUuid authenticatedUuid;
private final StaffApplicationDataMapper staffApplicationDataMapper; private final StaffApplicationDataMapper staffApplicationDataMapper;
private final StaffApplicationMail staffApplicationMail; private final StaffApplicationMail staffApplicationMail;
private final StaffApplicationDiscord staffApplicationDiscord; private final StaffApplicationDiscord staffApplicationDiscord;
@ -55,7 +56,7 @@ public class ApplicationController implements ApplicationsApi {
if (!isOpen()) { if (!isOpen()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
} }
UUID userUuid = AuthenticatedUuid.getAuthenticatedUserUuid(); UUID userUuid = authenticatedUuid.getAuthenticatedUserUuid();
String email = staffApplicationDto.getEmail() == null ? null : staffApplicationDto.getEmail().toLowerCase(); String email = staffApplicationDto.getEmail() == null ? null : staffApplicationDto.getEmail().toLowerCase();
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, email); Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, email);

View File

@ -28,11 +28,12 @@ import java.util.concurrent.TimeUnit;
public class MailController implements MailApi { public class MailController implements MailApi {
private final MailVerificationService mailVerificationService; private final MailVerificationService mailVerificationService;
private final AuthenticatedUuid authenticatedUuid;
@Override @Override
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailSubmit") @RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailSubmit")
public ResponseEntity<MailResponseDto> submitEmailForVerification(SubmitEmailDto submitEmailDto) { public ResponseEntity<MailResponseDto> submitEmailForVerification(SubmitEmailDto submitEmailDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
boolean emailAlreadyVerified = mailVerificationService.listAll(uuid).stream() boolean emailAlreadyVerified = mailVerificationService.listAll(uuid).stream()
.filter(EmailVerification::verified) .filter(EmailVerification::verified)
.map(EmailVerification::email) .map(EmailVerification::email)
@ -52,7 +53,7 @@ public class MailController implements MailApi {
@Override @Override
@RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify") @RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify")
public ResponseEntity<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) { public ResponseEntity<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
Optional<EmailVerification> optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode()); Optional<EmailVerification> optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode());
if (optionalEmailVerification.isEmpty()) { if (optionalEmailVerification.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code");
@ -68,7 +69,7 @@ public class MailController implements MailApi {
@Override @Override
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend") @RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend")
public ResponseEntity<MailResponseDto> resendVerificationEmail(SubmitEmailDto submitEmailDto) { public ResponseEntity<MailResponseDto> resendVerificationEmail(SubmitEmailDto submitEmailDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
EmailVerification updated = mailVerificationService.resend(uuid, submitEmailDto.getEmail()); EmailVerification updated = mailVerificationService.resend(uuid, submitEmailDto.getEmail());
if (updated == null) { if (updated == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
@ -83,7 +84,7 @@ public class MailController implements MailApi {
@Override @Override
@RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete") @RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete")
public ResponseEntity<Void> deleteEmail(SubmitEmailDto submitEmailDto) { public ResponseEntity<Void> deleteEmail(SubmitEmailDto submitEmailDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail()); boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail());
if (!deleted) { if (!deleted) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
@ -93,7 +94,7 @@ public class MailController implements MailApi {
@Override @Override
public ResponseEntity<List<EmailEntryDto>> getUserEmails() { public ResponseEntity<List<EmailEntryDto>> getUserEmails() {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
List<EmailVerification> emails = mailVerificationService.listAll(uuid); List<EmailVerification> emails = mailVerificationService.listAll(uuid);
List<EmailEntryDto> result = emails.stream() List<EmailEntryDto> result = emails.stream()
.map(ev -> new EmailEntryDto().email(ev.email()).verified(ev.verified())) .map(ev -> new EmailEntryDto().email(ev.email()).verified(ev.verified()))

View File

@ -9,6 +9,7 @@ import com.alttd.altitudeweb.setup.Connection;
import com.alttd.altitudeweb.database.Databases; import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.litebans.*; import com.alttd.altitudeweb.database.litebans.*;
import com.alttd.altitudeweb.model.PunishmentHistoryDto; import com.alttd.altitudeweb.model.PunishmentHistoryDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
@ -22,8 +23,11 @@ import java.util.concurrent.TimeUnit;
@Slf4j @Slf4j
@RestController @RestController
@RateLimit(limit = 30, timeValue = 10, timeUnit = TimeUnit.SECONDS) @RateLimit(limit = 30, timeValue = 10, timeUnit = TimeUnit.SECONDS)
@RequiredArgsConstructor
public class HistoryApiController implements HistoryApi { public class HistoryApiController implements HistoryApi {
private final AuthenticatedUuid authenticatedUuid;
@Override @Override
public ResponseEntity<PunishmentHistoryListDto> getHistoryForAll(String userType, String type, Integer page) { public ResponseEntity<PunishmentHistoryListDto> getHistoryForAll(String userType, String type, Integer page) {
return getHistoryForUsers(userType, type, "", page); return getHistoryForUsers(userType, type, "", page);
@ -232,10 +236,9 @@ public class HistoryApiController implements HistoryApi {
.id(historyRecord.getId()); .id(historyRecord.getId());
} }
// Admin edit endpoints (restricted to head_mod scope)
@Override @Override
@PreAuthorize("hasAuthority('SCOPE_head_mod')")
public ResponseEntity<PunishmentHistoryDto> updatePunishmentReason(String type, Integer id, String reason) { public ResponseEntity<PunishmentHistoryDto> updatePunishmentReason(String type, Integer id, String reason) {
log.debug("Updating reason for {} id {} to {}", type, id, reason);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type); HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>(); CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
@ -250,7 +253,7 @@ public class HistoryApiController implements HistoryApi {
} }
int changed = editMapper.setReason(historyTypeEnum, id, reason); int changed = editMapper.setReason(historyTypeEnum, id, reason);
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id); HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
UUID actor = AuthenticatedUuid.getAuthenticatedUserUuid(); UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})", log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})",
actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed); actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed);
result.complete(after != null ? mapPunishmentHistory(after) : null); result.complete(after != null ? mapPunishmentHistory(after) : null);
@ -267,8 +270,8 @@ public class HistoryApiController implements HistoryApi {
} }
@Override @Override
@PreAuthorize("hasAuthority('SCOPE_head_mod')")
public ResponseEntity<PunishmentHistoryDto> updatePunishmentUntil(String type, Integer id, Long until) { public ResponseEntity<PunishmentHistoryDto> updatePunishmentUntil(String type, Integer id, Long until) {
log.debug("Updating until for {} id {} to {}", type, id, until);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type); HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>(); CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
@ -283,7 +286,7 @@ public class HistoryApiController implements HistoryApi {
} }
int changed = editMapper.setUntil(historyTypeEnum, id, until); int changed = editMapper.setUntil(historyTypeEnum, id, until);
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id); HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
UUID actor = AuthenticatedUuid.getAuthenticatedUserUuid(); UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})", log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})",
actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed); actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed);
result.complete(after != null ? mapPunishmentHistory(after) : null); result.complete(after != null ? mapPunishmentHistory(after) : null);
@ -303,8 +306,8 @@ public class HistoryApiController implements HistoryApi {
} }
@Override @Override
@PreAuthorize("hasAuthority('SCOPE_head_mod')")
public ResponseEntity<Void> removePunishment(String type, Integer id) { public ResponseEntity<Void> removePunishment(String type, Integer id) {
log.debug("Removing punishment for {} id {}", type, id);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type); HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<Boolean> result = new CompletableFuture<>(); CompletableFuture<Boolean> result = new CompletableFuture<>();
@ -317,7 +320,7 @@ public class HistoryApiController implements HistoryApi {
result.complete(false); result.complete(false);
return; return;
} }
UUID actorUuid = AuthenticatedUuid.getAuthenticatedUserUuid(); UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString()); String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString());
int changed = editMapper.remove(historyTypeEnum, id); int changed = editMapper.remove(historyTypeEnum, id);
log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})", log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})",

View File

@ -37,6 +37,7 @@ import java.util.concurrent.TimeUnit;
public class LoginController implements LoginApi { public class LoginController implements LoginApi {
private final JwtEncoder jwtEncoder; private final JwtEncoder jwtEncoder;
private final AuthenticatedUuid authenticatedUuid;
@Value("${login.secret:#{null}}") @Value("${login.secret:#{null}}")
private String loginSecret; private String loginSecret;
@ -99,7 +100,7 @@ public class LoginController implements LoginApi {
try { try {
// Get authenticated UUID using the utility method // Get authenticated UUID using the utility method
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
log.debug("Loaded username for logged in user {}", uuid); log.debug("Loaded username for logged in user {}", uuid);
// Create response with username // Create response with username

View File

@ -22,10 +22,11 @@ import java.util.concurrent.TimeUnit;
public class SiteController implements SiteApi { public class SiteController implements SiteApi {
private final VoteService voteService; private final VoteService voteService;
private final AuthenticatedUuid authenticatedUuid;
@Override @Override
public ResponseEntity<VoteDataDto> getVoteStats() { public ResponseEntity<VoteDataDto> getVoteStats() {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid); Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid);
if (optionalVoteDataDto.isEmpty()) { if (optionalVoteDataDto.isEmpty()) {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();

View File

@ -1,4 +1,4 @@
cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080} cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080,http://localhost:80}
my-server.address=${SERVER_ADDRESS:http://localhost:8080} my-server.address=${SERVER_ADDRESS:http://localhost:8080}
logging.level.com.alttd.altitudeweb=DEBUG logging.level.com.alttd.altitudeweb=DEBUG
logging.level.org.springframework.security=DEBUG logging.level.org.springframework.security=DEBUG

View File

@ -6,13 +6,25 @@
<input matInput type="text" [(ngModel)]="reason" placeholder="Enter reason"/> <input matInput type="text" [(ngModel)]="reason" placeholder="Enter reason"/>
</mat-form-field> </mat-form-field>
<mat-checkbox [(ngModel)]="isPermanent">Permanent</mat-checkbox> <mat-checkbox [(ngModel)]="isPermanent"><span style="color: black">Permanent</span></mat-checkbox>
@if (!isPermanent) { @if (!isPermanent) {
<mat-form-field appearance="fill"> <div class="datetime-container">
<mat-label>Expires</mat-label> <mat-form-field appearance="fill" class="date-field">
<input matInput type="datetime-local" [(ngModel)]="expiresAtLocal" [disabled]="isPermanent"/> <mat-label>Expiry Date</mat-label>
</mat-form-field> <input matInput [matDatepicker]="dateTimePicker" [(ngModel)]="expiryDate" [disabled]="isPermanent">
<mat-hint>MM/DD/YYYY</mat-hint>
<mat-datepicker-toggle matIconSuffix [for]="dateTimePicker"></mat-datepicker-toggle>
<mat-datepicker #dateTimePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="fill" class="time-field">
<mat-label>Expiry Time</mat-label>
<input matInput type="time" [(ngModel)]="expiryTime" [disabled]="isPermanent">
<mat-hint>HH:MM (24-hour)</mat-hint>
</mat-form-field>
<p>Time in timezone: {{ getTimezone() }}</p>
</div>
} }
@if (errorMessage()) { @if (errorMessage()) {

View File

@ -5,6 +5,26 @@
margin-top: 8px; margin-top: 8px;
} }
.error { .datetime-container {
color: #d32f2f; /* Material error color */ display: flex;
flex-wrap: wrap;
gap: 16px;
.date-field {
flex: 1;
min-width: 180px;
}
.time-field {
min-width: 120px;
}
p {
color: #beb8b8;
}
}
.error {
color: red;
margin-top: 16px;
} }

View File

@ -13,6 +13,7 @@ import {
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import {HistoryService, PunishmentHistory} from '@api'; import {HistoryService, PunishmentHistory} from '@api';
import {firstValueFrom} from 'rxjs'; import {firstValueFrom} from 'rxjs';
import {MatDatepickerModule} from '@angular/material/datepicker';
interface EditPunishmentData { interface EditPunishmentData {
punishment: PunishmentHistory; punishment: PunishmentHistory;
@ -31,19 +32,22 @@ interface EditPunishmentData {
MatDialogTitle, MatDialogTitle,
MatDialogContent, MatDialogContent,
MatDialogActions, MatDialogActions,
MatDatepickerModule,
], ],
templateUrl: './edit-punishment-dialog.component.html', templateUrl: './edit-punishment-dialog.component.html',
styleUrl: './edit-punishment-dialog.component.scss' styleUrl: './edit-punishment-dialog.component.scss'
}) })
export class EditPunishmentDialogComponent { export class EditPunishmentDialogComponent {
// Form model // Form model
reason: string = ''; protected reason: string = '';
isPermanent: boolean = false; protected isPermanent: boolean = false;
expiresAtLocal: string = ''; protected expiresAt: Date;
protected expiryDate: Date | null = null;
protected expiryTime: string = '';
// UI state // UI state
isBusy = signal<boolean>(false); protected isBusy = signal<boolean>(false);
errorMessage = signal<string | null>(null); protected errorMessage = signal<string | null>(null);
private historyApi = inject(HistoryService); private historyApi = inject(HistoryService);
@ -53,17 +57,15 @@ export class EditPunishmentDialogComponent {
) { ) {
const punishment = data.punishment; const punishment = data.punishment;
this.reason = punishment.reason ?? ''; this.reason = punishment.reason ?? '';
const permanent = punishment.expiryTime <= 0; this.isPermanent = punishment.expiryTime <= 0;
this.isPermanent = permanent; this.expiresAt = new Date(punishment.expiryTime);
if (!permanent) {
const date = new Date(punishment.expiryTime); if (this.expiresAt && !isNaN(this.expiresAt.getTime())) {
const pad = (n: number) => n.toString().padStart(2, '0'); this.expiryDate = new Date(this.expiresAt);
const year = date.getFullYear();
const month = pad(date.getMonth() + 1); const hours = this.expiresAt.getHours().toString().padStart(2, '0');
const day = pad(date.getDate()); const minutes = this.expiresAt.getMinutes().toString().padStart(2, '0');
const hours = pad(date.getHours()); this.expiryTime = `${hours}:${minutes}`;
const minutes = pad(date.getMinutes());
this.expiresAtLocal = `${year}-${month}-${day}T${hours}:${minutes}`;
} }
} }
@ -76,12 +78,23 @@ export class EditPunishmentDialogComponent {
private computeUntilMs(): number { private computeUntilMs(): number {
if (this.isPermanent) { if (this.isPermanent) {
return -1; return 0;
} }
if (!this.expiresAtLocal) {
return -1; if (!this.expiryDate) {
return 0;
} }
const ms = new Date(this.expiresAtLocal).getTime();
const combinedDateTime = new Date(this.expiryDate);
if (this.expiryTime) {
const [hours, minutes] = this.expiryTime.split(':').map(Number);
combinedDateTime.setHours(hours, minutes, 0, 0);
}
this.expiresAt = combinedDateTime;
const ms = combinedDateTime.getTime();
return isNaN(ms) ? -1 : ms; return isNaN(ms) ? -1 : ms;
} }
@ -95,17 +108,15 @@ export class EditPunishmentDialogComponent {
const updates: Array<Promise<PunishmentHistory>> = [] as any; const updates: Array<Promise<PunishmentHistory>> = [] as any;
// Update reason if changed
if ((this.reason ?? '') !== (punishment.reason ?? '')) { if ((this.reason ?? '') !== (punishment.reason ?? '')) {
console.log('Changing reason to ', this.reason, ' from ', punishment.reason, '')
updates.push(firstValueFrom(this.historyApi.updatePunishmentReason(punishment.type, punishment.id, this.reason))); updates.push(firstValueFrom(this.historyApi.updatePunishmentReason(punishment.type, punishment.id, this.reason)));
} }
// Update expiry for ban/mute only const newUntil = this.computeUntilMs();
if (punishment.type === 'ban' || punishment.type === 'mute') { if (newUntil !== punishment.expiryTime) {
const newUntil = this.computeUntilMs(); console.log('Changing until to ', newUntil, ' from ', punishment.expiryTime, '')
if (newUntil !== punishment.expiryTime) { updates.push(firstValueFrom(this.historyApi.updatePunishmentUntil(punishment.type, punishment.id, newUntil)));
updates.push(firstValueFrom(this.historyApi.updatePunishmentUntil(punishment.type, punishment.id, newUntil)));
}
} }
if (updates.length === 0) { if (updates.length === 0) {
@ -147,4 +158,8 @@ export class EditPunishmentDialogComponent {
} }
}) })
} }
getTimezone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'Unknown';
}
} }

View File

@ -60,7 +60,7 @@ export class HistoryFormatService {
public getAvatarUrl(entry: string, size: string = '25'): string { public getAvatarUrl(entry: string, size: string = '25'): string {
let uuid = entry.replace('-', ''); let uuid = entry.replace('-', '');
if (uuid === 'C') { if (uuid === 'C' || uuid === 'Console' || uuid === '[Console]') {
uuid = "f78a4d8dd51b4b3998a3230f2de0c670" uuid = "f78a4d8dd51b4b3998a3230f2de0c670"
} }
return `https://crafatar.com/avatars/${uuid}?size=${size}&overlay`; return `https://crafatar.com/avatars/${uuid}?size=${size}&overlay`;

View File

@ -1,4 +1,4 @@
import {Component, EventEmitter, Input, OnChanges, OnInit, Output} from '@angular/core'; import {Component, EventEmitter, inject, Input, OnChanges, OnInit, Output} from '@angular/core';
import {HistoryService, PunishmentHistory} from '@api'; import {HistoryService, PunishmentHistory} from '@api';
import {catchError, map, Observable, shareReplay} from 'rxjs'; import {catchError, map, Observable, shareReplay} from 'rxjs';
import {NgOptimizedImage} from '@angular/common'; import {NgOptimizedImage} from '@angular/common';
@ -38,12 +38,11 @@ export class HistoryComponent implements OnInit, OnChanges {
public history: PunishmentHistory[] = [] public history: PunishmentHistory[] = []
constructor(private historyApi: HistoryService, private historyApi: HistoryService = inject(HistoryService)
public historyFormat: HistoryFormatService, public historyFormat: HistoryFormatService = inject(HistoryFormatService)
private router: Router, private router: Router = inject(Router)
private authService: AuthService, private authService: AuthService = inject(AuthService)
private dialog: MatDialog) { private dialog: MatDialog = inject(MatDialog)
}
ngOnChanges(): void { ngOnChanges(): void {
this.reloadHistory(); this.reloadHistory();
@ -116,7 +115,8 @@ export class HistoryComponent implements OnInit, OnChanges {
public openEdit(punishment: PunishmentHistory) { public openEdit(punishment: PunishmentHistory) {
if (!this.canEdit()) return; if (!this.canEdit()) return;
const ref = this.dialog.open(EditPunishmentDialogComponent, { const ref = this.dialog.open(EditPunishmentDialogComponent, {
data: {punishment} data: {punishment},
width: '500px',
}); });
ref.afterClosed().subscribe(result => { ref.afterClosed().subscribe(result => {
if (result) { if (result) {

View File

@ -4,6 +4,7 @@ import {provideRouter} from '@angular/router';
import {routes} from './app/app.routes'; import {routes} from './app/app.routes';
import {provideHttpClient, withInterceptors} from '@angular/common/http'; import {provideHttpClient, withInterceptors} from '@angular/common/http';
import {authInterceptor} from '@services/AuthInterceptor'; import {authInterceptor} from '@services/AuthInterceptor';
import {provideNativeDateAdapter} from '@angular/material/core';
bootstrapApplication(AppComponent, { bootstrapApplication(AppComponent, {
providers: [ providers: [
@ -12,6 +13,7 @@ bootstrapApplication(AppComponent, {
provideHttpClient( provideHttpClient(
withInterceptors([authInterceptor]) withInterceptors([authInterceptor])
), ),
provideNativeDateAdapter()
] ]
}).catch(err => console.error(err)); }).catch(err => console.error(err));