Author SHA1 Message Date
stijn 8bfcdb6ccc Replace edit button with a Material icon, adjust styles, and add MatIconModule to component imports. 2025-10-24 21:28:20 +02:00
stijn 64ea68ab39 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. 2025-10-24 21:10:34 +02:00
stijn f117cb2477 Remove debug console.log statements from auth.service.ts. 2025-10-24 19:52:26 +02:00
stijn d84d0c7fef Add conditional button styling and logic to indicate vote availability based on last vote timestamp. 2025-10-24 19:50:48 +02:00
stijn 00bf7caec2 Add vote statistics feature and improve vote page functionality 2025-10-24 19:39:08 +02:00
stijn 41dab473b0 Add admin endpoints for editing and removing punishments and implement frontend dialog for punishment management 2025-10-23 23:52:52 +02:00
stijn b71ea7da8b Limit returned usernames to 1 2025-10-21 22:48:21 +02:00
stijn a55806e5dd make scheduled methoded protected, fix connection 2025-10-21 22:22:43 +02:00
stijn 7e25cc583c Enable scheduling so code cache gets cleared 2025-10-21 22:17:08 +02:00
stijn 894dfac0c6 Validate weekly playtime as a whole number in staff application form. 2025-10-19 02:39:56 +02:00
stijn 300d33da7d Prevent duplicate staff application submissions by disabling the submit button during processing. 2025-10-18 23:10:56 +02:00
stijn 6f6801c728 Include applicant's username in staff application emails and Discord notifications. 2025-10-18 23:07:03 +02:00
stijn f8157e997a Update staff application close date to 2025-10-26 to match open period 2025-10-18 02:53:32 +02:00
stijn 74e8697fef Enhance "Staff applications closed" message with improved styling and structure. 2025-10-18 02:49:51 +02:00
stijn 8ad87da47e Update player count text to include context on home page 2025-10-18 02:46:31 +02:00
stijn 29a28e712e Add player count display with periodic updates to home page 2025-10-18 02:43:23 +02:00
stijn 6ad3b5221a Update Grove map button label to "Bayou" 2025-10-18 02:31:27 +02:00
stijn 1b697fcaa3 Add redirections for appeal and staff application forms paths 2025-10-17 22:05:00 +02:00
stijn ed9d41cdc6 Add conditional display logic to staff application form based on open/close status 2025-10-17 22:00:23 +02:00
stijn 5eaeb3552a Add API endpoint to check staff application availability and enforce open/close periods 2025-10-17 21:42:32 +02:00
stijn 8e9e267fb0 Update grove map button link to point bayou map 2025-10-17 20:11:23 +02:00
stijn 6d8f73201f Restrict "Particles" dropdown link visibility based on HEAD_MOD permission claim. 2025-10-12 22:05:20 +02:00
stijn 91e5a2a9a0 Refactor form validation and add user feedback with snackbar notifications in staff application form. 2025-10-12 21:57:40 +02:00
stijn 0005b3b6d4 Set login dialog width to 400px in auth guard. 2025-10-12 21:42:58 +02:00
stijn b3999b3389 Prompt login dialog when user is unauthenticated during auth guard check. 2025-10-12 21:40:54 +02:00
stijn 5a4df2572d Revert "Prompt login dialog when no JWT is found during authentication check."
This reverts commit e3fd0944df.
2025-10-12 21:37:54 +02:00
stijn e697f7ca90 Revert "Add navigation to current URL after login dialog completion"
This reverts commit 3da46c203b.
2025-10-12 21:37:53 +02:00
stijn 3da46c203b Add navigation to current URL after login dialog completion 2025-10-12 21:34:59 +02:00
stijn e3fd0944df Prompt login dialog when no JWT is found during authentication check. 2025-10-12 21:33:51 +02:00
stijn cd34cd93ad Update checkbox styles to use theme font color. 2025-10-12 21:29:04 +02:00
stijn a9294d1115 Update staff application email subject formatting 2025-10-12 21:24:46 +02:00
stijn 745dab4d80 Update query in SettingsMapper to use internal_name instead of name for database identification. 2025-10-12 21:16:16 +02:00
stijn dc65b19a8f Add error handling and logging improvements for database settings loading process. 2025-10-12 21:04:59 +02:00
stijn 01dab905d4 Improve staff application flow with error handling updates, new email address, and code formatting adjustments. 2025-09-27 21:13:04 +02:00
stijn 311d77fcb2 Enhance staff application flow with email verification checks, refined error handling, and improved user feedback in frontend and backend. 2025-09-27 20:00:44 +02:00
54 changed files with 1876 additions and 645 deletions
@@ -9,6 +9,7 @@ import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.configurers.AbstractHttpConfigurer;
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.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import java.security.KeyPair;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@@ -47,13 +54,14 @@ public class SecurityConfig {
.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/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/login/userLogin/**").permitAll()
.anyRequest().permitAll()
)
.csrf(AbstractHttpConfigurer::disable)
.oauth2ResourceServer(
oauth2 -> oauth2
.jwt(Customizer.withDefaults())
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
.authenticationEntryPoint(securityAuthFailureHandler)
.accessDeniedHandler(securityAuthFailureHandler)
)
@@ -84,4 +92,46 @@ public class SecurityConfig {
KeyPair keyPair = keyPairService.getJwtSigningKeyPair();
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;
}
}
@@ -18,7 +18,7 @@ public class CorsConfig implements WebMvcConfigurer {
log.info("Registering CORS mappings for {}", String.join(", ", allowedOrigins));
registry.addMapping("/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true);
}
@@ -1,24 +1,33 @@
package com.alttd.altitudeweb.controllers.data_from_auth;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.UUID;
@Service
public class AuthenticatedUuid {
@Value("${UNSECURED:#{false}}")
private boolean unsecured;
/**
* Extracts and validates the authenticated user's UUID from the JWT token.
*
* @return The UUID of the authenticated user
* @throws ResponseStatusException with 401 status if authentication is invalid
*/
public static UUID getAuthenticatedUserUuid() {
public UUID getAuthenticatedUserUuid() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
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");
}
@@ -3,6 +3,7 @@ package com.alttd.altitudeweb.controllers.forms;
import com.alttd.altitudeweb.api.ApplicationsApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
@@ -16,9 +17,11 @@ import com.alttd.altitudeweb.services.mail.StaffApplicationMail;
import com.alttd.altitudeweb.setup.Connection;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -30,53 +33,68 @@ import java.util.concurrent.TimeUnit;
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class ApplicationController implements ApplicationsApi {
private final AuthenticatedUuid authenticatedUuid;
private final StaffApplicationDataMapper staffApplicationDataMapper;
private final StaffApplicationMail staffApplicationMail;
private final StaffApplicationDiscord staffApplicationDiscord;
private final Instant open = Instant.parse("2025-10-18T00:00:00Z");
private final Instant close = Instant.parse("2025-10-26T00:00:00Z");
@Override
public ResponseEntity<Boolean> getStaffApplicationsIsOpen() {
return ResponseEntity.ok(isOpen());
}
private boolean isOpen() {
Instant now = Instant.now();
return !now.isBefore(open) && !now.isAfter(close);
}
@Override
public ResponseEntity<FormResponseDto> submitStaffApplication(StaffApplicationDto staffApplicationDto) {
UUID userUuid = AuthenticatedUuid.getAuthenticatedUserUuid();
if (!isOpen()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
UUID userUuid = authenticatedUuid.getAuthenticatedUserUuid();
String email = staffApplicationDto.getEmail() == null ? null : staffApplicationDto.getEmail().toLowerCase();
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, email);
if (optionalEmail.isEmpty() || !optionalEmail.get().verified()) {
log.warn("User {} attempted to submit an application without a verified email {}", userUuid, email);
return ResponseEntity.badRequest().build();
}
// Map and persist application
StaffApplication application = staffApplicationDataMapper.map(userUuid, staffApplicationDto);
saveApplication(application);
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, application.email());
boolean verified = optionalEmail.map(EmailVerification::verified).orElse(false);
String username = getUsername(userUuid);
boolean success = true;
if (verified) {
// Send mail first; only if sent, send to Discord, then mark as sent
boolean mailSent = false;
try {
mailSent = staffApplicationMail.sendApplicationEmail(application);
} catch (Exception e) {
log.error("Error while sending staff application email for {}", application.id(), e);
success = false;
try {
if (!staffApplicationMail.sendApplicationEmail(username, application)) {
log.warn("Failed to send staff application email for {}", application.id());
return ResponseEntity.internalServerError().build();
}
if (mailSent) {
try {
staffApplicationDiscord.sendApplicationToDiscord(application);
} catch (Exception e) {
log.error("Failed to send staff application {} to Discord", application.id(), e);
success = false;
}
} else {
success = false;
}
if (success) {
markAsSent(application.id());
}
}
if (verified && !success) {
} catch (Exception e) {
log.error("Error while sending staff application email for {}", application.id(), e);
return ResponseEntity.internalServerError().build();
}
FormResponseDto response = buildResponse(application, verified);
return ResponseEntity.status(201).body(response);
try {
staffApplicationDiscord.sendApplicationToDiscord(username, application);
} catch (Exception e) {
log.error("Failed to send staff application {} to Discord", application.id(), e);
return ResponseEntity.internalServerError().build();
}
try {
markAsSent(application.id());
} catch (Exception e) {
log.error("Failed to mark application {} as sent", application.id(), e);
return ResponseEntity.internalServerError().build();
}
FormResponseDto response = buildResponse(application);
return ResponseEntity.status(200).body(response);
}
private void saveApplication(StaffApplication application) {
@@ -110,14 +128,28 @@ public class ApplicationController implements ApplicationsApi {
.runQuery(sqlSession -> sqlSession.getMapper(StaffApplicationMapper.class).markAsSent(applicationId));
}
private FormResponseDto buildResponse(StaffApplication application, boolean verified) {
String message = verified
? "Your staff application has been submitted. You will be notified when it has been reviewed."
: "Application created. Please verify your email to complete submission.";
private FormResponseDto buildResponse(StaffApplication application) {
String message = "Your staff application has been submitted. You will be notified when it has been reviewed.";
return new FormResponseDto(
application.id().toString(),
message,
true
);
}
private String getUsername(UUID uuid) {
CompletableFuture<String> usernameFuture = new CompletableFuture<>();
Connection.getConnection(Databases.LUCK_PERMS)
.runQuery(sqlSession -> {
log.debug("Loading username for uuid {}", uuid);
try {
String username = sqlSession.getMapper(UUIDUsernameMapper.class).getUsernameFromUUID(uuid.toString());
usernameFuture.complete(username);
} catch (Exception e) {
log.error("Failed to load username for uuid {}", uuid, e);
usernameFuture.completeExceptionally(e);
}
});
return usernameFuture.join();
}
}
@@ -28,11 +28,12 @@ import java.util.concurrent.TimeUnit;
public class MailController implements MailApi {
private final MailVerificationService mailVerificationService;
private final AuthenticatedUuid authenticatedUuid;
@Override
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailSubmit")
public ResponseEntity<MailResponseDto> submitEmailForVerification(SubmitEmailDto submitEmailDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
boolean emailAlreadyVerified = mailVerificationService.listAll(uuid).stream()
.filter(EmailVerification::verified)
.map(EmailVerification::email)
@@ -52,7 +53,7 @@ public class MailController implements MailApi {
@Override
@RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify")
public ResponseEntity<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
Optional<EmailVerification> optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode());
if (optionalEmailVerification.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code");
@@ -68,7 +69,7 @@ public class MailController implements MailApi {
@Override
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend")
public ResponseEntity<MailResponseDto> resendVerificationEmail(SubmitEmailDto submitEmailDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
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");
@@ -83,7 +84,7 @@ public class MailController implements MailApi {
@Override
@RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete")
public ResponseEntity<Void> deleteEmail(SubmitEmailDto submitEmailDto) {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail());
if (!deleted) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user");
@@ -93,7 +94,7 @@ public class MailController implements MailApi {
@Override
public ResponseEntity<List<EmailEntryDto>> getUserEmails() {
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
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()))
@@ -1,6 +1,7 @@
package com.alttd.altitudeweb.controllers.history;
import com.alttd.altitudeweb.api.HistoryApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.model.HistoryCountDto;
import com.alttd.altitudeweb.model.PunishmentHistoryListDto;
@@ -8,9 +9,11 @@ import com.alttd.altitudeweb.setup.Connection;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.litebans.*;
import com.alttd.altitudeweb.model.PunishmentHistoryDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@@ -20,8 +23,11 @@ import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@RateLimit(limit = 30, timeValue = 10, timeUnit = TimeUnit.SECONDS)
@RequiredArgsConstructor
public class HistoryApiController implements HistoryApi {
private final AuthenticatedUuid authenticatedUuid;
@Override
public ResponseEntity<PunishmentHistoryListDto> getHistoryForAll(String userType, String type, Integer page) {
return getHistoryForUsers(userType, type, "", page);
@@ -229,4 +235,108 @@ public class HistoryApiController implements HistoryApi {
.type(type)
.id(historyRecord.getId());
}
@Override
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);
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
try {
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
if (before == null) {
result.complete(null);
return;
}
int changed = editMapper.setReason(historyTypeEnum, id, reason);
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})",
actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed);
result.complete(after != null ? mapPunishmentHistory(after) : null);
} catch (Exception e) {
log.error("Failed to update reason for {} id {}", type, id, e);
result.completeExceptionally(e);
}
});
PunishmentHistoryDto body = result.join();
if (body == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(body);
}
@Override
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);
CompletableFuture<PunishmentHistoryDto> result = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
try {
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
if (before == null) {
result.complete(null);
return;
}
int changed = editMapper.setUntil(historyTypeEnum, id, until);
HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id);
UUID actor = authenticatedUuid.getAuthenticatedUserUuid();
log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})",
actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed);
result.complete(after != null ? mapPunishmentHistory(after) : null);
} catch (IllegalArgumentException e) {
log.warn("Invalid until edit for type {} id {}: {}", type, id, e.getMessage());
result.complete(null);
} catch (Exception e) {
log.error("Failed to update until for {} id {}", type, id, e);
result.completeExceptionally(e);
}
});
PunishmentHistoryDto body = result.join();
if (body == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(body);
}
@Override
public ResponseEntity<Void> removePunishment(String type, Integer id) {
log.debug("Removing punishment for {} id {}", type, id);
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
CompletableFuture<Boolean> result = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> {
try {
IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class);
EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class);
HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id);
if (before == null) {
result.complete(false);
return;
}
UUID actorUuid = authenticatedUuid.getAuthenticatedUserUuid();
String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString());
int changed = editMapper.remove(historyTypeEnum, id);
log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})",
actorName, actorUuid, historyTypeEnum, id,
before.getRemovedByName() == null ? 1 : 0, before.getRemovedByName(), before.getRemovedByReason(),
changed);
result.complete(changed > 0);
} catch (Exception e) {
log.error("Failed to remove punishment for {} id {}", type, id, e);
result.completeExceptionally(e);
}
});
Boolean ok = result.join();
if (ok == null || !ok) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.noContent().build();
}
}
@@ -2,26 +2,21 @@ package com.alttd.altitudeweb.controllers.login;
import com.alttd.altitudeweb.api.LoginApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.litebans.RecentNamesMapper;
import com.alttd.altitudeweb.database.litebans.UUIDHistoryMapper;
import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.litebans.RecentNamesMapper;
import com.alttd.altitudeweb.database.web_db.PrivilegedUser;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
import com.alttd.altitudeweb.model.PermissionClaimDto;
import com.alttd.altitudeweb.model.UsernameDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import com.alttd.altitudeweb.services.limits.RateLimit;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
@@ -37,10 +32,12 @@ import java.util.concurrent.TimeUnit;
@Slf4j
@RequiredArgsConstructor
@EnableScheduling
@RestController
public class LoginController implements LoginApi {
private final JwtEncoder jwtEncoder;
private final AuthenticatedUuid authenticatedUuid;
@Value("${login.secret:#{null}}")
private String loginSecret;
@@ -54,7 +51,7 @@ public class LoginController implements LoginApi {
private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
@Scheduled(fixedRate = 300000) // 5 minutes in milliseconds
private void clearExpiredCacheEntries() {
protected void clearExpiredCacheEntries() {
Instant now = Instant.now();
int initialCacheSize = cache.size();
cache.entrySet().removeIf(entry -> entry.getValue().expiry().isBefore(now));
@@ -71,6 +68,8 @@ public class LoginController implements LoginApi {
return ResponseEntity.badRequest().build();
}
log.info("{} is requesting a login code", uuid);
if (authorization == null || !authorization.startsWith("SECRET ")) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
@@ -86,10 +85,12 @@ public class LoginController implements LoginApi {
.findFirst();
if (key.isPresent()) {
log.info("{} got cached key: {}", uuid, key.get());
return ResponseEntity.ok(key.get());
}
String loginCode = generateLoginCode(uuidFromString);
log.info("{} received login code: {}", uuid, loginCode);
return ResponseEntity.ok(loginCode);
}
@@ -99,7 +100,7 @@ public class LoginController implements LoginApi {
try {
// Get authenticated UUID using the utility method
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
log.debug("Loaded username for logged in user {}", uuid);
// Create response with username
@@ -0,0 +1,37 @@
package com.alttd.altitudeweb.controllers.site;
import com.alttd.altitudeweb.api.SiteApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.model.VoteDataDto;
import com.alttd.altitudeweb.model.VoteStatsDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.site.VoteService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@RequiredArgsConstructor
@RateLimit(limit = 2, timeValue = 10, timeUnit = TimeUnit.SECONDS)
public class SiteController implements SiteApi {
private final VoteService voteService;
private final AuthenticatedUuid authenticatedUuid;
@Override
public ResponseEntity<VoteDataDto> getVoteStats() {
UUID uuid = authenticatedUuid.getAuthenticatedUserUuid();
Optional<VoteDataDto> optionalVoteDataDto = voteService.getVoteStats(uuid);
if (optionalVoteDataDto.isEmpty()) {
return ResponseEntity.noContent().build();
}
VoteDataDto voteDataDto = optionalVoteDataDto.get();
return ResponseEntity.ok(voteDataDto);
}
}
@@ -0,0 +1,62 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.votingplugin.VotingStatsRow;
import com.alttd.altitudeweb.model.VoteDataDto;
import com.alttd.altitudeweb.model.VoteInfoDto;
import com.alttd.altitudeweb.model.VoteStatsDto;
import com.alttd.altitudeweb.model.VoteStreakDto;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
@Service
public class VotingStatsRowToVoteDataDto {
public VoteDataDto map(VotingStatsRow votingStatsRow) {
VoteDataDto voteDataDto = new VoteDataDto();
voteDataDto.setVoteStats(getVoteStats(votingStatsRow));
voteDataDto.setVoteStreak(getVoteStreak(votingStatsRow));
voteDataDto.setBestVoteStreak(getBestVoteStreak(votingStatsRow));
voteDataDto.setAllVoteInfo(getVoteInfo(votingStatsRow.lastVotes()));
return voteDataDto;
}
private VoteStreakDto getVoteStreak(VotingStatsRow votingStatsRow) {
VoteStreakDto voteStreakDto = new VoteStreakDto();
voteStreakDto.setDailyStreak(votingStatsRow.dailyStreak());
voteStreakDto.setWeeklyStreak(votingStatsRow.weeklyStreak());
voteStreakDto.setMonthlyStreak(votingStatsRow.monthlyStreak());
return voteStreakDto;
}
private VoteStreakDto getBestVoteStreak(VotingStatsRow votingStatsRow) {
VoteStreakDto voteStreakDto = new VoteStreakDto();
voteStreakDto.setDailyStreak(votingStatsRow.bestDailyStreak());
voteStreakDto.setWeeklyStreak(votingStatsRow.bestWeeklyStreak());
voteStreakDto.setMonthlyStreak(votingStatsRow.bestMonthlyStreak());
return voteStreakDto;
}
private VoteStatsDto getVoteStats(VotingStatsRow votingStatsRow) {
VoteStatsDto voteStatsDto = new VoteStatsDto();
voteStatsDto.setDaily(votingStatsRow.totalVotesToday());
voteStatsDto.setWeekly(votingStatsRow.totalVotesThisWeek());
voteStatsDto.setMonthly(votingStatsRow.totalVotesThisMonth());
voteStatsDto.setTotal(votingStatsRow.totalVotesAllTime());
return voteStatsDto;
}
private List<VoteInfoDto> getVoteInfo(String lastVotes) {
return Arrays.stream(lastVotes.split("%line%"))
.map(voteInfo -> {
String[] siteAndTimestamp = voteInfo.split("//");
VoteInfoDto voteInfoDto = new VoteInfoDto();
voteInfoDto.setSiteName(siteAndTimestamp[0]);
voteInfoDto.setLastVoteTimestamp(Long.parseLong(siteAndTimestamp[1]));
return voteInfoDto;
})
.toList();
}
}
@@ -22,7 +22,7 @@ public class StaffApplicationDiscord {
private static final String OUTPUT_TYPE = "STAFF_APPLICATION";
public void sendApplicationToDiscord(StaffApplication application) {
public void sendApplicationToDiscord(String username, StaffApplication application) {
// Fetch channels for staff applications
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
@@ -46,7 +46,7 @@ public class StaffApplicationDiscord {
List<DiscordSender.EmbedField> fields = new ArrayList<>();
fields.add(new DiscordSender.EmbedField(
"Applicant",
"UUID: " + safe(String.valueOf(application.uuid())) + "\n" +
"Username: " + safe(username) + "\n" +
"Discord: " + safe(application.discordUsername()) + "\n" +
"Email: " + safe(application.email()) + "\n" +
"Age: " + safe(String.valueOf(application.age())) + "\n" +
@@ -26,15 +26,15 @@ public class StaffApplicationMail {
@Value("${spring.mail.username}")
private String fromEmail;
private static final String STAFF_APPLICATION_EMAIL = "staff@alttd.com";
private static final String STAFF_APPLICATION_EMAIL = "apply@alttd.com";
/**
* Sends an email with the staff application details to the staff applications team mailbox.
* Returns true if the email was sent successfully.
*/
public boolean sendApplicationEmail(StaffApplication application) {
public boolean sendApplicationEmail(String username, StaffApplication application) {
try {
doSend(application);
doSend(username, application);
log.info("Staff application email sent successfully for application ID: {}", application.id());
return true;
} catch (Exception e) {
@@ -43,14 +43,14 @@ public class StaffApplicationMail {
}
}
private void doSend(StaffApplication application) throws MessagingException {
private void doSend(String username, StaffApplication application) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(fromEmail);
helper.setTo(STAFF_APPLICATION_EMAIL);
helper.setReplyTo(application.email());
helper.setSubject("New Staff Application - " + safe(application.discordUsername()));
helper.setSubject("Staff Application: " + safe(application.discordUsername()));
// Prepare template context
String createdAt = application.createdAt()
@@ -59,6 +59,7 @@ public class StaffApplicationMail {
Context context = new Context();
context.setVariable("application", application);
context.setVariable("createdAt", createdAt);
context.setVariable("username", username);
String content = templateEngine.process("staff-application-email", context);
helper.setText(content, true);
@@ -0,0 +1,47 @@
package com.alttd.altitudeweb.services.site;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper;
import com.alttd.altitudeweb.database.votingplugin.VotingStatsRow;
import com.alttd.altitudeweb.mappers.VotingStatsRowToVoteDataDto;
import com.alttd.altitudeweb.model.VoteDataDto;
import com.alttd.altitudeweb.setup.Connection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
@RequiredArgsConstructor
public class VoteService {
private final VotingStatsRowToVoteDataDto votingStatsRowToVoteDataDto;
public Optional<VoteDataDto> getVoteStats(UUID uuid) {
CompletableFuture<Optional<VoteDataDto>> voteDataDtoFuture = new CompletableFuture<>();
Connection.getConnection(Databases.VOTING_PLUGIN).runQuery(sqlSession -> {
try {
VotingPluginUsersMapper votingPluginUsersMapper = sqlSession.getMapper(VotingPluginUsersMapper.class);
Optional<VotingStatsRow> optionalVotingStatsRow = votingPluginUsersMapper.getStatsByUuid(uuid);
if (optionalVotingStatsRow.isEmpty()) {
log.debug("No voting stats found for {}", uuid);
voteDataDtoFuture.complete(Optional.empty());
return;
}
VotingStatsRow votingStatsRow = optionalVotingStatsRow.get();
VoteDataDto voteDataDto = votingStatsRowToVoteDataDto.map(votingStatsRow);
voteDataDtoFuture.complete(Optional.of(voteDataDto));
} catch (Exception e) {
log.error("Failed to get vote data for {}", uuid, e);
voteDataDtoFuture.completeExceptionally(e);
}
});
return voteDataDtoFuture.join();//TODO handle exception
}
}
@@ -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}
logging.level.com.alttd.altitudeweb=DEBUG
logging.level.org.springframework.security=DEBUG
@@ -102,7 +102,7 @@
<div>
<h2>Applicant</h2>
<ul>
<li><strong>UUID:</strong> <span th:text="${application.uuid}">uuid</span></li>
<li><strong>Username:</strong> <span th:text="${username}">uuid</span></li>
<li><strong>Email:</strong> <span th:text="${application.email}">email</span></li>
<li><strong>Discord:</strong> <span th:text="${application.discordUsername}">discord</span></li>
<li><strong>Age:</strong> <span th:text="${application.age}">age</span></li>
@@ -7,7 +7,8 @@ public enum Databases {
DEFAULT("web_db"),
LUCK_PERMS("luckperms"),
LITE_BANS("litebans"),
DISCORD("discordLink");
DISCORD("discordLink"),
VOTING_PLUGIN("votingplugin");
private final String internalName;
@@ -0,0 +1,64 @@
package com.alttd.altitudeweb.database.litebans;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import org.jetbrains.annotations.NotNull;
public interface EditHistoryMapper {
@Update("""
UPDATE ${table_name}
SET reason = #{reason}
WHERE id = #{id}
""")
int updateReason(@Param("table_name") String tableName,
@Param("id") int id,
@Param("reason") String reason);
@Update("""
UPDATE ${table_name}
SET until = #{until}
WHERE id = #{id}
""")
int updateUntil(@Param("table_name") String tableName,
@Param("id") int id,
@Param("until") Long until);
@Delete("""
DELETE FROM ${table_name}
WHERE id = #{id}
""")
int deletePunishment(@Param("table_name") String tableName,
@Param("id") int id);
default int setReason(@NotNull HistoryType type, int id, String reason) {
return switch (type) {
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
case BAN -> updateReason("litebans_bans", id, reason);
case MUTE -> updateReason("litebans_mutes", id, reason);
case KICK -> updateReason("litebans_kicks", id, reason);
case WARN -> updateReason("litebans_warnings", id, reason);
};
}
default int setUntil(@NotNull HistoryType type, int id, Long until) {
return switch (type) {
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
case BAN -> updateUntil("litebans_bans", id, until);
case MUTE -> updateUntil("litebans_mutes", id, until);
case KICK -> throw new IllegalArgumentException("KICK has no until");
case WARN -> throw new IllegalArgumentException("WARN has no until");
};
}
default int remove(@NotNull HistoryType type, int id) {
return switch (type) {
case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported");
case BAN -> deletePunishment("litebans_bans", id);
case MUTE -> deletePunishment("litebans_mutes", id);
case KICK -> deletePunishment("litebans_kicks", id);
case WARN -> deletePunishment("litebans_warnings", id);
};
}
}
@@ -12,7 +12,8 @@ public interface RecentNamesMapper {
SELECT DISTINCT name AS username
FROM litebans_history
WHERE uuid = #{uuid}
ORDER BY date DESC;
ORDER BY date DESC
LIMIT 1;
""")
String getUsername(@Param("uuid") String uuid);
@@ -0,0 +1,13 @@
package com.alttd.altitudeweb.database.luckperms;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
public interface UUIDUsernameMapper {
@Select("""
SELECT username
FROM luckperms_players
WHERE uuid = #{uuid}
""")
String getUsernameFromUUID(@Param("uuid") String uuid);
}
@@ -0,0 +1,28 @@
package com.alttd.altitudeweb.database.votingplugin;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Optional;
import java.util.UUID;
public interface VotingPluginUsersMapper {
@Select("""
SELECT
LastVotes as lastVotes,
BestDayVoteStreak as bestDailyStreak,
BestWeekVoteStreak as bestWeeklyStreak,
BestMonthVoteStreak as bestMonthlyStreak,
DayVoteStreak as dailyStreak,
WeekVoteStreak as weeklyStreak,
MonthVoteStreak as monthlyStreak,
DailyTotal as totalVotesToday,
WeeklyTotal as totalVotesThisWeek,
MonthTotal as totalVotesThisMonth,
AllTimeTotal as totalVotesAllTime
FROM votingplugin.votingplugin_users
WHERE uuid = #{uuid}
""")
Optional<VotingStatsRow> getStatsByUuid(@Param("uuid") UUID uuid);
}
@@ -0,0 +1,15 @@
package com.alttd.altitudeweb.database.votingplugin;
public record VotingStatsRow(
String lastVotes,
Integer bestDailyStreak,
Integer bestWeeklyStreak,
Integer bestMonthlyStreak,
Integer dailyStreak,
Integer weeklyStreak,
Integer monthlyStreak,
Integer totalVotesToday,
Integer totalVotesThisWeek,
Integer totalVotesThisMonth,
Integer totalVotesAllTime
) { }
@@ -3,6 +3,6 @@ package com.alttd.altitudeweb.database.web_db;
import org.apache.ibatis.annotations.Select;
public interface SettingsMapper {
@Select("SELECT host, port, name, username, password FROM db_connection_settings WHERE name = #{database}")
@Select("SELECT host, port, name, username, password FROM db_connection_settings WHERE internal_name = #{database}")
DatabaseSettings getSettings(String database);
}
@@ -17,6 +17,7 @@ import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Consumer;
@Slf4j
@@ -37,6 +38,7 @@ public class Connection {
InitializeLiteBans.init();
InitializeLuckPerms.init();
InitializeDiscord.init();
InitializeVotingPlugin.init();
}
@FunctionalInterface
@@ -58,25 +60,46 @@ public class Connection {
if (database == Databases.DEFAULT) {
return loadDefaultDatabase(addMappers);
}
log.debug("Loading settings for database {}", database.getInternalName());
CompletableFuture<DatabaseSettings> settingsFuture = new CompletableFuture<>();
getConnection(Databases.DEFAULT, (mapper -> mapper.addMapper(SettingsMapper.class))).thenApply(connection -> {
log.debug("Loading settings for database {}", database.getInternalName());
connection.runQuery(session -> {
log.debug("Running query to load settings for database");
DatabaseSettings loadedSettings = session.getMapper(SettingsMapper.class).getSettings(database.getInternalName());
if (loadedSettings == null) {
log.error("Failed to load settings for database {}", database.getInternalName());
try {
log.debug("Running query to load settings for database {}", database.getInternalName());
DatabaseSettings loadedSettings = session.getMapper(SettingsMapper.class).getSettings(database.getInternalName());
if (loadedSettings == null) {
log.error("Failed to load settings for database {}. No settings found in db_connection_settings table.",
database.getInternalName());
settingsFuture.completeExceptionally(new IllegalStateException(
"Database settings for " + database.getInternalName() + " not found in db_connection_settings table"));
} else {
log.debug("Loaded settings for database {}: host={}, port={}, name={}",
database.getInternalName(), loadedSettings.host(), loadedSettings.port(), loadedSettings.name());
settingsFuture.complete(loadedSettings);
}
} catch (Exception e) {
log.error("Error occurred while loading database settings for {}", database.getInternalName(), e);
settingsFuture.completeExceptionally(e);
}
log.debug("Loaded settings {}", loadedSettings);
settingsFuture.complete(loadedSettings);
});
return null;
}).exceptionally(ex -> {
log.error("Failed to access DEFAULT database to load settings for {}", database.getInternalName(), ex);
settingsFuture.completeExceptionally(ex);
return null;
});
return settingsFuture.thenApply(loadedSettings -> {
log.debug("Storing connection for database {}", database.getInternalName());
Connection connection = new Connection(loadedSettings, addMappers);
connections.put(database, connection);
return connection;
}).exceptionally(ex -> {
log.error("Failed to create connection for database {}", database.getInternalName(), ex);
throw new CompletionException("Failed to initialize database connection for " + database.getInternalName(), ex);
});
}
@@ -141,10 +164,24 @@ public class Connection {
private static @NotNull Configuration getConfiguration(DatabaseSettings settings) {
PooledDataSource dataSource = new PooledDataSource();
dataSource.setDriver("com.mysql.cj.jdbc.Driver");
dataSource.setUrl(String.format("jdbc:mysql://%s:%d/%s", settings.host(),
settings.port(), settings.name()));
String url = String.format(
"jdbc:mysql://%s:%d/%s?useSSL=true&tcpKeepAlive=true&socketTimeout=60000&connectTimeout=10000&autoReconnect=false&useUnicode=true&characterEncoding=utf8",
settings.host(),
settings.port(),
settings.name()
);
dataSource.setUrl(url);
dataSource.setUsername(settings.username());
dataSource.setPassword(settings.password());
dataSource.setPoolMaximumActiveConnections(10);
dataSource.setPoolMaximumIdleConnections(5);
dataSource.setPoolTimeToWait(20000);
dataSource.setPoolPingEnabled(true);
dataSource.setPoolPingQuery("SELECT 1");
dataSource.setPoolPingConnectionsNotUsedFor(300000); // 5 min
Environment environment = new Environment("production", new JdbcTransactionFactory(), dataSource);
return new Configuration(environment);
}
@@ -19,6 +19,7 @@ public class InitializeLiteBans {
configuration.addMapper(UUIDHistoryMapper.class);
configuration.addMapper(HistoryCountMapper.class);
configuration.addMapper(IdHistoryMapper.class);
configuration.addMapper(EditHistoryMapper.class);
}).join()
.runQuery(sqlSession -> {
createAllPunishmentsView(sqlSession);
@@ -2,6 +2,7 @@ package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper;
import com.alttd.altitudeweb.database.luckperms.UUIDUsernameMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@@ -11,6 +12,7 @@ public class InitializeLuckPerms {
log.info("Initializing LuckPerms");
Connection.getConnection(Databases.LUCK_PERMS, (configuration) -> {
configuration.addMapper(TeamMemberMapper.class);
configuration.addMapper(UUIDUsernameMapper.class);
}).join();
log.debug("Initialized LuckPerms");
}
@@ -0,0 +1,17 @@
package com.alttd.altitudeweb.setup;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.votingplugin.VotingPluginUsersMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class InitializeVotingPlugin {
protected static void init() {
log.info("Initializing VotingPlugin");
Connection.getConnection(Databases.VOTING_PLUGIN, (configuration) -> {
configuration.addMapper(VotingPluginUsersMapper.class);
}).join();
log.debug("Initialized VotingPlugin");
}
}
@@ -20,22 +20,22 @@ public class InitializeWebDb {
protected static void init() {
log.info("Initializing WebDb");
Connection.getConnection(Databases.DEFAULT, (configuration) -> {
configuration.addMapper(SettingsMapper.class);
configuration.addMapper(KeyPairMapper.class);
configuration.addMapper(PrivilegedUserMapper.class);
configuration.addMapper(AppealMapper.class);
configuration.addMapper(StaffApplicationMapper.class);
configuration.addMapper(EmailVerificationMapper.class);
}).join()
.runQuery(sqlSession -> {
createSettingsTable(sqlSession);
createKeyTable(sqlSession);
createPrivilegedUsersTable(sqlSession);
createPrivilegesTable(sqlSession);
createAppealTable(sqlSession);
createStaffApplicationsTable(sqlSession);
createUserEmailsTable(sqlSession);
});
configuration.addMapper(SettingsMapper.class);
configuration.addMapper(KeyPairMapper.class);
configuration.addMapper(PrivilegedUserMapper.class);
configuration.addMapper(AppealMapper.class);
configuration.addMapper(StaffApplicationMapper.class);
configuration.addMapper(EmailVerificationMapper.class);
}).join()
.runQuery(sqlSession -> {
createSettingsTable(sqlSession);
createKeyTable(sqlSession);
createPrivilegedUsersTable(sqlSession);
createPrivilegesTable(sqlSession);
createAppealTable(sqlSession);
createStaffApplicationsTable(sqlSession);
createUserEmailsTable(sqlSession);
});
log.debug("Initialized WebDb");
}
+10
View File
@@ -110,6 +110,11 @@ export const routes: Routes = [
path: 'forms',
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
},
{
path: 'appeal',
redirectTo: 'forms/appeal',
pathMatch: 'full'
},
{
path: 'forms/appeal',
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
@@ -126,6 +131,11 @@ export const routes: Routes = [
requiredAuthorizations: ['SCOPE_user']
}
},
{
path: 'apply',
redirectTo: 'forms/staff-application',
pathMatch: 'full'
},
{
path: 'forms/staff-application',
loadComponent: () => import('./pages/forms/staff-application/staff-application.component').then(m => m.StaffApplicationComponent),
+18 -4
View File
@@ -1,7 +1,9 @@
import {Injectable} from '@angular/core';
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
import {Observable} from 'rxjs';
import {map, Observable} from 'rxjs';
import {AuthService} from '@services/auth.service';
import {MatDialog} from '@angular/material/dialog';
import {LoginDialogComponent} from '@shared-components/login/login.component';
@Injectable({
providedIn: 'root'
@@ -10,7 +12,8 @@ export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router
private router: Router,
private dialog: MatDialog
) {
}
@@ -18,8 +21,19 @@ export class AuthGuard implements CanActivate {
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
if (!this.authService.checkAuthStatus()) {
return this.router.createUrlTree(['/']);
if (!this.authService.isAuthenticated$()) {
this.router.createUrlTree(['/']);
const dialogRef = this.dialog.open(LoginDialogComponent, {
width: '400px',
})
return dialogRef.afterClosed().pipe(
map(result => {
if (result) {
return this.router.createUrlTree([state.url]);
}
return this.router.createUrlTree(['/']);
})
);
}
const requiredAuthorizations = route.data['requiredAuthorizations'] as string[];
@@ -6,9 +6,9 @@
<h1>Dynamic Server Maps</h1>
<h2>Which server would you like to see?</h2>
<div>
<a href="https://gmap.alttd.com">
<a href="https://bmap.alttd.com">
<div style="background-color: #F547B2;" class="button-outer map-button">
<span class="button-inner">Grove</span>
<span class="button-inner">Bayou</span>
</div>
</a>
<a href="https://cmap.alttd.com">
@@ -6,304 +6,329 @@
</div>
</app-header>
<main>
<section class="darkmodeSection staff-application-container">
<div class="form-container">
<div class="pages">
<!-- Welcome Page -->
@if (currentPageIndex === 0) {
<section class="formPage">
<img ngSrc="/public/img/logos/logo.png" alt="Logo" height="319" width="550"/>
<h1>Moderator Application</h1>
<p>Thank you for your interest in becoming a moderator on our Minecraft server.</p>
<p>Please take your time to fill out this application thoroughly.</p>
<button mat-raised-button (click)="nextPage()">
Get Started
</button>
</section>
}
<!-- Confirmation Page -->
@if (currentPageIndex === 1) {
<section class="formPage">
<div class="description">
<p>You are logged in as <strong>{{ authService.username() }}</strong>. If this is the correct account
please continue</p>
<br>
<p><strong>Notice: </strong> Submitting a staff application is <strong>not</strong> an instant process.
We will review your application carefully and get back to you if we think you're a good fit.</p>
<p style="font-style: italic;">Applications that seem to have been made with
little to no effort will be automatically rejected.</p>
</div>
<button mat-raised-button (click)="nextPage()" [disabled]="authService.username() == null">
I, {{ authService.username() }}, understand and agree
</button>
</section>
}
<form [formGroup]="form">
<!-- Basic Information Page -->
@if (currentPageIndex === 2) {
@if (staffApplicationsIsOpen()) {
<section class="darkmodeSection staff-application-container">
<div class="form-container">
<div class="pages">
<!-- Welcome Page -->
@if (currentPageIndex === 0) {
<section class="formPage">
<div class="description">
<h2>Basic Information</h2>
<!-- Email -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Email</mat-label>
<input matInput
formControlName="email"
placeholder="Email">
@if (form.controls.email.invalid && form.controls.email.touched) {
<mat-error>
@if (form.controls.email.errors?.['required']) {
Email is required
} @else if (form.controls.email.errors?.['email']) {
Please enter a valid email address
}
</mat-error>
}
</mat-form-field>
@if (emailIsValid()) {
<div class="valid-email">
<ng-container matSuffix>
<mat-icon>check</mat-icon>
<span>You have validated your email previously, and can continue to the next page!</span>
</ng-container>
</div>
}
<!-- Age -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Age</mat-label>
<input matInput
formControlName="age"
placeholder="Age">
@if (form.controls.age.invalid && form.controls.age.touched) {
<mat-error>
@if (form.controls.age.errors?.['required']) {
Age is required
} @else if (form.controls.age.errors?.['min']) {
You must be at least 13 years old
} @else if (form.controls.age.errors?.['pattern']) {
Please enter a valid number
}
</mat-error>
}
</mat-form-field>
<!-- Discord Username -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Discord Username</mat-label>
<input matInput
formControlName="discordUsername"
placeholder="Discord Username">
@if (form.controls.discordUsername.invalid && form.controls.discordUsername.touched) {
<mat-error>
Discord username is required
</mat-error>
}
</mat-form-field>
<!-- PC Requirements -->
<div class="checkbox-field">
<mat-checkbox formControlName="meetsRequirements">
I confirm that I meet the PC requirements (able to record video at 30fps 720p or higher, and able to talk in voice chat)
</mat-checkbox>
@if (form.controls.meetsRequirements.invalid && form.controls.meetsRequirements.touched) {
<mat-error class="checkbox-error">
You must meet the PC requirements to apply
</mat-error>
}
</div>
<!-- Pronouns (Optional) -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Pronouns (Optional)</mat-label>
<input matInput
formControlName="pronouns"
placeholder="Pronouns">
</mat-form-field>
</div>
<button mat-raised-button (click)="validateMailOrNextPage()"
[disabled]="form.controls.email.invalid || form.controls.age.invalid || form.controls.discordUsername.invalid || !form.controls.meetsRequirements.value">
Next
<img ngSrc="/public/img/logos/logo.png" alt="Logo" height="319" width="550"/>
<h1>Moderator Application</h1>
<p>Thank you for your interest in becoming a moderator on our Minecraft server.</p>
<p>Please take your time to fill out this application thoroughly.</p>
<button mat-raised-button (click)="nextPage()">
Get Started
</button>
</section>
}
<!-- Experience Page -->
@if (currentPageIndex === 3) {
<!-- Confirmation Page -->
@if (currentPageIndex === 1) {
<section class="formPage">
<div class="description">
<h2>Experience & Availability</h2>
<p>You are logged in as <strong>{{ authService.username() }}</strong>. If this is the correct account
please continue</p>
<br>
<p><strong>Notice: </strong> Submitting a staff application is <strong>not</strong> an instant
process.
We will review your application carefully and get back to you if we think you're a good fit.</p>
<p style="font-style: italic;">Applications that seem to have been made with
little to no effort will be automatically rejected.</p>
</div>
<button mat-raised-button (click)="nextPage()" [disabled]="authService.username() == null">
I, {{ authService.username() }}, understand and agree
</button>
</section>
}
<!-- Join Date -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>When did you join our server? (Estimate)</mat-label>
<input matInput [matDatepicker]="picker" formControlName="joinDate">
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
@if (form.controls.joinDate.invalid && form.controls.joinDate.touched) {
<mat-error>
Join date is required
</mat-error>
<form [formGroup]="form">
<!-- Basic Information Page -->
@if (currentPageIndex === 2) {
<section class="formPage">
<div class="description">
<h2>Basic Information</h2>
<!-- Email -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Email</mat-label>
<input matInput
formControlName="email"
placeholder="Email">
@if (form.controls.email.invalid && form.controls.email.touched) {
<mat-error>
@if (form.controls.email.errors?.['required']) {
Email is required
} @else if (form.controls.email.errors?.['email']) {
Please enter a valid email address
}
</mat-error>
}
</mat-form-field>
@if (emailIsValid()) {
<div class="valid-email">
<ng-container matSuffix>
<mat-icon>check</mat-icon>
<span>You have validated your email previously.</span>
</ng-container>
</div>
} @else {
<div class="invalid-email">
<ng-container matSuffix>
<mat-icon>close</mat-icon>
<span>You have not used this email address before. Before going to the next page you will be asked to verify it.</span>
</ng-container>
</div>
}
</mat-form-field>
<!-- Weekly Playtime -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Average expected playtime in a week (hours)</mat-label>
<input matInput
formControlName="weeklyPlaytime"
placeholder="Hours per week">
@if (form.controls.weeklyPlaytime.invalid && form.controls.weeklyPlaytime.touched) {
<mat-error>
@if (form.controls.weeklyPlaytime.errors?.['required']) {
Weekly playtime is required
} @else if (form.controls.weeklyPlaytime.errors?.['min']) {
Weekly playtime must be at least 1 hour
}
</mat-error>
}
</mat-form-field>
<!-- Age -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Age</mat-label>
<input matInput
formControlName="age"
placeholder="Age">
@if (form.controls.age.invalid && form.controls.age.touched) {
<mat-error>
@if (form.controls.age.errors?.['required']) {
Age is required
} @else if (form.controls.age.errors?.['min']) {
You must be at least 13 years old
} @else if (form.controls.age.errors?.['pattern']) {
Please enter a valid number
}
</mat-error>
}
</mat-form-field>
<!-- Available Days -->
<div class="field-container">
<label class="field-label">Available days for moderating:</label>
<div class="days-container">
@for (day of availableDays; track day) {
<div class="day-chip" [class.selected]="form.controls.availableDays.value.includes(day)" (click)="toggleDay(day)">
{{ day }}
</div>
<!-- Discord Username -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Discord Username</mat-label>
<input matInput
formControlName="discordUsername"
placeholder="Discord Username">
@if (form.controls.discordUsername.invalid && form.controls.discordUsername.touched) {
<mat-error>
Discord username is required
</mat-error>
}
</mat-form-field>
<!-- PC Requirements -->
<div class="checkbox-field">
<mat-checkbox formControlName="meetsRequirements">
I confirm that I meet the PC requirements (able to record video at 30fps 720p or higher, and
able
to talk in voice chat)
</mat-checkbox>
@if (form.controls.meetsRequirements.invalid && form.controls.meetsRequirements.touched) {
<mat-error class="checkbox-error">
You must meet the PC requirements to apply
</mat-error>
}
</div>
@if (form.controls.availableDays.invalid && form.controls.availableDays.touched) {
<mat-error>
Please select at least one day
</mat-error>
}
<!-- Pronouns (Optional) -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Pronouns (Optional)</mat-label>
<input matInput
formControlName="pronouns"
placeholder="Pronouns">
</mat-form-field>
</div>
<button mat-raised-button (click)="validateMailOrNextPage()"
[disabled]="form.controls.email.invalid || form.controls.age.invalid || form.controls.discordUsername.invalid || !form.controls.meetsRequirements.value">
Next
</button>
</section>
}
<!-- Available Times -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Available times (Your timezone: {{ userTimezone }})</mat-label>
<textarea matInput
formControlName="availableTimes"
placeholder="e.g., 6PM-10PM weekdays, 2PM-8PM weekends"
rows="2"></textarea>
@if (form.controls.availableTimes.invalid && form.controls.availableTimes.touched) {
<mat-error>
Available times are required
</mat-error>
}
</mat-form-field>
</div>
<button mat-raised-button (click)="nextPage()"
[disabled]="form.controls.joinDate.invalid || form.controls.weeklyPlaytime.invalid || form.controls.availableDays.invalid || form.controls.availableTimes.invalid">
Next
</button>
</section>
}
<!-- Experience Page -->
@if (currentPageIndex === 3) {
<section class="formPage">
<div class="description">
<h2>Experience & Availability</h2>
<!-- Qualifications Page -->
@if (currentPageIndex === 4) {
<section class="formPage">
<div class="description">
<h2>Qualifications & Expectations</h2>
<!-- Join Date -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>When did you join our server? (Estimate)</mat-label>
<input matInput [matDatepicker]="picker" formControlName="joinDate">
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
@if (form.controls.joinDate.invalid && form.controls.joinDate.touched) {
<mat-error>
Join date is required
</mat-error>
}
</mat-form-field>
<!-- Previous Experience -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Previous experience (here or in other relevant places)</mat-label>
<textarea matInput
formControlName="previousExperience"
placeholder="Describe your previous experience"
rows="4"></textarea>
@if (form.controls.previousExperience.invalid && form.controls.previousExperience.touched) {
<mat-error>
@if (form.controls.previousExperience.errors?.['required']) {
Previous experience is required
} @else if (form.controls.previousExperience.errors?.['minlength']) {
Please provide more details (at least 10 characters)
<!-- Weekly Playtime -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Average expected playtime in a week (hours)</mat-label>
<input matInput
formControlName="weeklyPlaytime"
placeholder="Hours per week">
@if (form.controls.weeklyPlaytime.invalid && form.controls.weeklyPlaytime.touched) {
<mat-error>
@if (form.controls.weeklyPlaytime.errors?.['required']) {
Weekly playtime is required
} @else if (form.controls.weeklyPlaytime.errors?.['min']) {
Weekly playtime must be at least 1 hour
} @else if (form.controls.weeklyPlaytime.errors?.['pattern']) {
Please enter a whole number of hours (e.g., 20)
}
</mat-error>
}
</mat-form-field>
<!-- Available Days -->
<div class="field-container">
<label class="field-label">Available days for moderating:</label>
<div class="days-container">
@for (day of availableDays; track day) {
<div class="day-chip" [class.selected]="form.controls.availableDays.value.includes(day)"
(click)="toggleDay(day)">
{{ day }}
</div>
}
</mat-error>
}
</mat-form-field>
</div>
@if (form.controls.availableDays.invalid && form.controls.availableDays.touched) {
<mat-error>
Please select at least one day
</mat-error>
}
</div>
<!-- Plugin Experience -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Experience with plugins that players use on our server</mat-label>
<textarea matInput
formControlName="pluginExperience"
placeholder="Describe your experience with our server plugins"
rows="4"></textarea>
@if (form.controls.pluginExperience.invalid && form.controls.pluginExperience.touched) {
<mat-error>
@if (form.controls.pluginExperience.errors?.['required']) {
Plugin experience is required
} @else if (form.controls.pluginExperience.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
<!-- Available Times -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Available times (Your timezone: {{ userTimezone }})</mat-label>
<textarea matInput
formControlName="availableTimes"
placeholder="e.g., 6PM-10PM weekdays, 2PM-8PM weekends"
rows="2"></textarea>
@if (form.controls.availableTimes.invalid && form.controls.availableTimes.touched) {
<mat-error>
Available times are required
</mat-error>
}
</mat-form-field>
</div>
<button mat-raised-button (click)="nextPage()"
[disabled]="form.controls.joinDate.invalid || form.controls.weeklyPlaytime.invalid || form.controls.availableDays.invalid || form.controls.availableTimes.invalid">
Next
</button>
</section>
}
<!-- Moderator Expectations -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>What do you believe the expectations of a moderator are?</mat-label>
<textarea matInput
formControlName="moderatorExpectations"
placeholder="Describe what you think a moderator should do"
rows="4"></textarea>
@if (form.controls.moderatorExpectations.invalid && form.controls.moderatorExpectations.touched) {
<mat-error>
@if (form.controls.moderatorExpectations.errors?.['required']) {
Moderator expectations are required
} @else if (form.controls.moderatorExpectations.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
<!-- Qualifications Page -->
@if (currentPageIndex === 4) {
<section class="formPage">
<div class="description">
<h2>Qualifications & Expectations</h2>
<!-- Additional Information -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Additional Information (Optional)</mat-label>
<textarea matInput
formControlName="additionalInfo"
placeholder="Any additional information you'd like to share"
rows="4"></textarea>
</mat-form-field>
</div>
<button mat-raised-button (click)="onSubmit()"
[disabled]="form.controls.previousExperience.invalid || form.controls.pluginExperience.invalid || form.controls.moderatorExpectations.invalid">
Submit Application
</button>
</section>
}
</form>
</div>
<!-- Previous Experience -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Previous experience (here or in other relevant places)</mat-label>
<textarea matInput
formControlName="previousExperience"
placeholder="Describe your previous experience"
rows="4"></textarea>
@if (form.controls.previousExperience.invalid && form.controls.previousExperience.touched) {
<mat-error>
@if (form.controls.previousExperience.errors?.['required']) {
Previous experience is required
} @else if (form.controls.previousExperience.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
<!-- Navigation dots -->
@if (totalPages.length > 1) {
<div class="form-navigation">
<button mat-icon-button class="nav-button" (click)="previousPage()" [disabled]="isFirstPage()">
<mat-icon>navigate_before</mat-icon>
</button>
<!-- Plugin Experience -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Experience with plugins that players use on our server</mat-label>
<textarea matInput
formControlName="pluginExperience"
placeholder="Describe your experience with our server plugins"
rows="4"></textarea>
@if (form.controls.pluginExperience.invalid && form.controls.pluginExperience.touched) {
<mat-error>
@if (form.controls.pluginExperience.errors?.['required']) {
Plugin experience is required
} @else if (form.controls.pluginExperience.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
@for (i of totalPages; track i) {
<div
class="nav-dot"
[class.active]="i === currentPageIndex"
(click)="goToPage(i)">
</div>
}
<!-- Moderator Expectations -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>What do you believe the expectations of a moderator are?</mat-label>
<textarea matInput
formControlName="moderatorExpectations"
placeholder="Describe what you think a moderator should do"
rows="4"></textarea>
@if (form.controls.moderatorExpectations.invalid && form.controls.moderatorExpectations.touched) {
<mat-error>
@if (form.controls.moderatorExpectations.errors?.['required']) {
Moderator expectations are required
} @else if (form.controls.moderatorExpectations.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
<button mat-icon-button class="nav-button" (click)="nextPage()" [disabled]="isLastPage()">
<mat-icon>navigate_next</mat-icon>
</button>
<!-- Additional Information -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Additional Information (Optional)</mat-label>
<textarea matInput
formControlName="additionalInfo"
placeholder="Any additional information you'd like to share"
rows="4"></textarea>
</mat-form-field>
</div>
<button mat-raised-button (click)="onSubmit()"
[disabled]="isFormInvalid() || isSubmitting()">
Submit Application
</button>
</section>
}
</form>
</div>
}
</div>
</section>
<!-- Navigation dots -->
@if (totalPages.length > 1) {
<div class="form-navigation">
<button mat-icon-button class="nav-button" (click)="previousPage()" [disabled]="isFirstPage()">
<mat-icon>navigate_before</mat-icon>
</button>
@for (i of totalPages; track i) {
<div
class="nav-dot"
[class.active]="i === currentPageIndex"
(click)="goToPage(i)">
</div>
}
<button mat-icon-button class="nav-button" (click)="nextPage()" [disabled]="isLastPage()">
<mat-icon>navigate_next</mat-icon>
</button>
</div>
}
</div>
</section>
} @else {
<section class="darkmodeSection staff-application-container">
<div>
<h1>Staff applications closed</h1>
<p class="center">Staff applications are closed at this time. Please keep an eye on our announcement channel
in Discord to
see
when it opens!</p>
</div>
</section>
}
</main>
</div>
@@ -99,6 +99,16 @@ main {
background-color: rgba(76, 175, 80, 0.1);
}
.invalid-email {
display: flex;
align-items: center;
color: #af4c4c;
margin: 10px 0;
padding: 8px 12px;
border-radius: 4px;
background-color: rgba(76, 175, 80, 0.1);
}
.valid-email mat-icon {
color: #4CAF50;
margin-right: 10px;
@@ -27,6 +27,7 @@ import {MatCheckboxModule} from '@angular/material/checkbox';
import {MatDatepickerModule} from '@angular/material/datepicker';
import {MatNativeDateModule} from '@angular/material/core';
import {MatChipsModule} from '@angular/material/chips';
import {MatSnackBar} from '@angular/material/snack-bar';
@Component({
selector: 'app-staff-application',
@@ -51,10 +52,12 @@ import {MatChipsModule} from '@angular/material/chips';
export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewInit {
private mailService = inject(MailService);
private matSnackBar = inject(MatSnackBar);
public authService = inject(AuthService);
public staffApplicationService = inject(ApplicationsService)
private resizeObserver: ResizeObserver | null = null;
private boundHandleResize: any;
protected isSubmitting = signal<boolean>(false);
protected form: FormGroup<StaffApplicationForm>;
private emails = signal<EmailEntry[]>([]);
@@ -66,6 +69,7 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
protected availableDays: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
protected selectedDays: string[] = [];
protected userTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
protected staffApplicationsIsOpen = signal<boolean>(false)
constructor(
@@ -99,7 +103,7 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
}),
weeklyPlaytime: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.min(1)]
validators: [Validators.required, Validators.min(1), Validators.pattern('^[0-9]*$')]
}),
availableDays: new FormControl([], {
nonNullable: true,
@@ -107,7 +111,7 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
}),
availableTimes: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(1000)]
validators: [Validators.required, Validators.maxLength(900)]
}),
previousExperience: new FormControl('', {
nonNullable: true,
@@ -149,6 +153,9 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
}
ngOnInit() {
this.staffApplicationService.getStaffApplicationsIsOpen().subscribe(isOpen => {
this.staffApplicationsIsOpen.set(isOpen)
})
const uuid = this.authService.getUuid();
if (uuid === null) {
alert('Error retrieving token, please relog on the website and try again')
@@ -213,8 +220,11 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
}
public onSubmit() {
this.isSubmitting.set(true);
if (this.form === undefined) {
console.error('Form is undefined');
this.matSnackBar.open('An error occurred, please try again later')
this.isSubmitting.set(false);
return
}
if (this.form.valid) {
@@ -225,21 +235,42 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
const control = this.form.get(field);
control?.markAsTouched();
});
this.matSnackBar.open('Please fill out all required fields')
this.isSubmitting.set(false);
}
}
isFormInvalid() {
return !this.form.valid
}
private router = inject(Router)
private sendForm() {
const staffApplication: StaffApplication = this.mapToStaffApplication(this.form.getRawValue());
this.staffApplicationService.submitStaffApplication(staffApplication).subscribe(result => {
//TODO route to mail page
// Navigate to the sent page
this.router.navigate(['/forms/sent'], {
state: {message: 'Your staff application has been submitted successfully. We will review your application and get back to you soon.'}
}).then();
})
this.staffApplicationService.submitStaffApplication(staffApplication).subscribe({
next: result => {
if (!result.verified_mail) {
this.isSubmitting.set(false);
this.matSnackBar.open('Your email has not been verified. Please verify your email before submitting.', 'Close', {
duration: 5000
});
return;
}
this.router.navigate(['/forms/sent'], {
state: {message: result.message}
}).then();
},
error: (error) => {
this.isSubmitting.set(false);
console.error('Error submitting application:', error);
this.matSnackBar.open('An error occurred while submitting your application. Please try again later.', 'Close', {
duration: 5000
});
}
});
}
public currentPageIndex: number = 0;
@@ -321,7 +352,7 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
joinDate: joinDateString,
weeklyPlaytime: Number(formData.weeklyPlaytime),
availableDays: formData.availableDays,
availableTimes: formData.availableTimes,
availableTimes: `Timezone: ${this.userTimezone}\nAvailable Times: ${formData.availableTimes}`,
previousExperience: formData.previousExperience,
pluginExperience: formData.pluginExperience,
moderatorExpectations: formData.moderatorExpectations,
@@ -142,7 +142,9 @@
<a [id]="getCurrentPageId(['particles'])"
class="nav_link fake_link" [ngClass]="active">Special</a>
<ul class="dropdown">
<li class="nav_li"><a class="nav_link2" [routerLink]="['/particles']">Particles</a></li>
@if (hasAccess([PermissionClaim.HEAD_MOD])) {
<li class="nav_li"><a class="nav_link2" [routerLink]="['/particles']">Particles</a></li>
}
</ul>
</li>
}
@@ -6,6 +6,7 @@ import {AuthService} from '@services/auth.service';
import {Subscription} from 'rxjs';
import {LoginDialogComponent} from '@shared-components/login/login.component';
import {MatDialog} from '@angular/material/dialog';
import {PermissionClaim} from '@api';
@Component({
standalone: true,
@@ -90,4 +91,6 @@ export class HeaderComponent implements OnDestroy {
protected logout() {
this.authService.logout()
}
protected readonly PermissionClaim = PermissionClaim;
}
+141 -138
View File
@@ -1,157 +1,160 @@
<ng-container>
<app-header [current_page]="'home'" height="100vh"
[background_image]="'/public/img/backgrounds/120spawn-min.png'">
[background_image]="'/public/img/backgrounds/120spawn-min.png'">
<div class="title" header-content>
<h1 style="display: none;">Altitude</h1>
<img id="header-img" ngSrc="/public/img/logos/logo.png" alt="The Altitude Minecraft Server" height="319"
width="550">
<h2 style="font-size: 2.5em;" id="homeh2">Altitude now on {{ ALTITUDE_VERSION }}!</h2>
<a id="scroll-button" (click)="scrollToSection()">
<span></span>
<p style="display: none;">Scroll Down</p>
</a>
</div>
</app-header>
<main>
<section id="scrollingpoint" style="background: #202020; text-align: center; padding: 80px 0;">
<!-- TODO load player count from old api or backend?-->
width="550">
<h2 style="font-size: 2.5em;" id="homeh2">Altitude now on {{ ALTITUDE_VERSION }}!</h2>
<a id="scroll-button" (click)="scrollToSection()">
<span></span>
<p style="display: none;">Scroll Down</p>
</a>
</div>
</app-header>
<main>
<section id="scrollingpoint" style="background: #202020; text-align: center; padding: 80px 0;">
@if (this.playerCount() === null) {
<h2 style="color: white;"><span class="player-count">Loading...</span></h2>
<h2 style="color: white;">Server IP: play.alttd.com</h2>
<div style="padding-top: 35px;">
<app-copy-ip></app-copy-ip>
</div>
</section>
<section class="darkmodeSection">
<div class="container">
<div class="paragraph">
<h2>Adventure Begins</h2>
<p>You awake in a strange town, where are you? There are residents running about trading with each other and
stories of distant realms with more towns. It's time to write your story. Welcome to Altitude, the laid-back
} @else {
<h2 style="color: white;"><span class="player-count">Current players online: {{ playerCount() }}</span></h2>
}
<h2 style="color: white;">Server IP: play.alttd.com</h2>
<div style="padding-top: 35px;">
<app-copy-ip></app-copy-ip>
</div>
</section>
<section class="darkmodeSection">
<div class="container">
<div class="paragraph">
<h2>Adventure Begins</h2>
<p>You awake in a strange town, where are you? There are residents running about trading with each other and
stories of distant realms with more towns. It's time to write your story. Welcome to Altitude, the laid-back
community-oriented server that hosts your home for Minecraft.</p>
</div>
<img ngSrc="/public/img/items/bookquill.png" style="width: 150px; align-self: center; margin: 0 auto;"
alt="Alternative Altitude Server Logo"
height="150" width="150">
</div>
</section>
<!--
<section id="section1">
<div class="container" id="video">
<h2 style="display: none;">YouTube Trailer</h2>
<div style="border-radius:5px;overflow:hidden;position:relative;width:100%">
<img style="width: 100%" src="https://img.youtube.com/vi/Nzbj9Dbv5Wk/maxresdefault.jpg" alt="Altitude YouTube Trailer">
<div id="youtube">
<div class="play" onclick="playVideo()"></div>
</div>
<img ngSrc="/public/img/items/bookquill.png" style="width: 150px; align-self: center; margin: 0 auto;"
alt="Alternative Altitude Server Logo"
height="150" width="150">
</div>
</div>
</section>
-->
<section class="darkmodeSection">
<div class="container" style="padding: 10px 0 80px 0">
<iframe id="discord-widget" src="https://discordapp.com/widget?id=141644560005595136&theme=dark"></iframe>
<div class="paragraph" id="discord-p">
<h2>Meet the Community</h2>
<p>Altitude is home to players young and old from all around the globe, and here, everyone is family. Altitude
is your place to get together with friends and relax - and maybe enjoy some survival too. Altitude is
intended for older players, but all are welcome!</p>
<p>Don't have Discord? Keep up with news and announcements at the <a href="alttd.com/blog">blog</a>.</p>
<div style="display: flex; justify-content: center;">
<a target="_blank" rel="noopener" href="https://discordapp.com/invite/TGqpzCJ">
<div style="margin-top: 30px;" class="button-outer">
<span class="button-inner">Join Our Discord</span>
</div>
</a>
</div>
</section>
<!--
<section id="section1">
<div class="container" id="video">
<h2 style="display: none;">YouTube Trailer</h2>
<div style="border-radius:5px;overflow:hidden;position:relative;width:100%">
<img style="width: 100%" src="https://img.youtube.com/vi/Nzbj9Dbv5Wk/maxresdefault.jpg" alt="Altitude YouTube Trailer">
<div id="youtube">
<div class="play" onclick="playVideo()"></div>
</div>
</div>
</section>
<section id="section2">
<div class="customContainer">
<h2 style="color: white; padding-bottom: 35px; font-size: 2.8em;">Survival Shaped by You</h2>
<p style="color: white; padding-bottom: 35px; font-size: 1.1em; margin: auto;">Altitude is built by the
community, for the community. We've added features requested by our members and several custom plugins to
create our "perfect" survival experience.</p>
<div class="survivalShapedContainerPlugins">
<div class="pluginColumn">
<h2>McMMO & MyPet</h2>
<p>Two of the most requested plugins on Altitude, level up yourself and your pet with these MMO-based
plugins!</p>
<a [routerLink]="['/mypet']">
<div class="button-outer">
<span class="button-inner">MyPet Info</span>
</div>
</div>
</a>
</div>
</section>
-->
<section class="darkmodeSection">
<div class="container" style="padding: 10px 0 80px 0">
<iframe id="discord-widget" src="https://discordapp.com/widget?id=141644560005595136&theme=dark"></iframe>
<div class="paragraph" id="discord-p">
<h2>Meet the Community</h2>
<p>Altitude is home to players young and old from all around the globe, and here, everyone is family. Altitude
is your place to get together with friends and relax - and maybe enjoy some survival too. Altitude is
intended for older players, but all are welcome!</p>
<p>Don't have Discord? Keep up with news and announcements at the <a href="alttd.com/blog">blog</a>.</p>
<div style="display: flex; justify-content: center;">
<a target="_blank" rel="noopener" href="https://discordapp.com/invite/TGqpzCJ">
<div style="margin-top: 30px;" class="button-outer">
<span class="button-inner">Join Our Discord</span>
</div>
</a>
<div class="pluginColumn">
<h2>Dynamic map</h2>
<p>See the world and the players around it in real-time! The map shows the entire survival world with claims
and warps.</p>
<a [routerLink]="['/map']">
<div class="button-outer">
<span class="button-inner">Visit Map</span>
</div>
</div>
</a>
</div>
</section>
<section id="section2">
<div class="customContainer">
<h2 style="color: white; padding-bottom: 35px; font-size: 2.8em;">Survival Shaped by You</h2>
<p style="color: white; padding-bottom: 35px; font-size: 1.1em; margin: auto;">Altitude is built by the
community, for the community. We've added features requested by our members and several custom plugins to
create our "perfect" survival experience.</p>
<div class="survivalShapedContainerPlugins">
<div class="pluginColumn">
<h2>McMMO & MyPet</h2>
<p>Two of the most requested plugins on Altitude, level up yourself and your pet with these MMO-based
plugins!</p>
<a [routerLink]="['/mypet']">
<div class="button-outer">
<span class="button-inner">MyPet Info</span>
</div>
</a>
<div class="pluginColumn">
<h2>Player Shops</h2>
<p>Our economy is built on player shops, encouraging player interaction and putting the control in your
hands.</p>
<a [routerLink]="['/economy']">
<div class="button-outer">
<span class="button-inner">Shop Guide</span>
</div>
<div class="pluginColumn">
<h2>Dynamic map</h2>
<p>See the world and the players around it in real-time! The map shows the entire survival world with claims
and warps.</p>
<a [routerLink]="['/map']">
<div class="button-outer">
<span class="button-inner">Visit Map</span>
</div>
</a>
</div>
<div class="pluginColumn">
<h2>Player Shops</h2>
<p>Our economy is built on player shops, encouraging player interaction and putting the control in your
hands.</p>
<a [routerLink]="['/economy']">
<div class="button-outer">
<span class="button-inner">Shop Guide</span>
</div>
</a>
</div>
</div>
</a>
</div>
</section>
<section class="remove-mobile darkmodeSection">
<div class="customContainer">
<h2 style="padding-bottom: 35px; font-size: 2.8em;">Community Builds</h2>
<p style="padding-bottom: 35px; font-size: 1.1em; margin: auto;">Thank you to our brilliant players for sharing
their impressive builds. Take a look at some of them here!<br>
If you know of any builds that should be featured, please let us know!</p>
<div class="sliderWrapper">
<div class="sliderContent">
<div class="indexSlider">
<div id="go-left" (click)="previousSlide()" class="circleBehind goLeft"></div>
<div id="go-right" (click)="nextSlide()" class="circleBehind goRight"></div>
<div class="display">
</div>
</div>
</section>
<section class="remove-mobile darkmodeSection">
<div class="customContainer">
<h2 style="padding-bottom: 35px; font-size: 2.8em;">Community Builds</h2>
<p style="padding-bottom: 35px; font-size: 1.1em; margin: auto;">Thank you to our brilliant players for sharing
their impressive builds. Take a look at some of them here!<br>
If you know of any builds that should be featured, please let us know!</p>
<div class="sliderWrapper">
<div class="sliderContent">
<div class="indexSlider">
<div id="go-left" (click)="previousSlide()" class="circleBehind goLeft"></div>
<div id="go-right" (click)="nextSlide()" class="circleBehind goRight"></div>
<div class="display">
<span class="slide"
[style.background-image]="'url(' + slide + ')'"
[style.opacity]="carouselOpacity"
[style.display]="'block'"></span>
</div>
</div>
<div class="dots">
@for (slide of getSlideIndices(); track slide) {
<span class="dot" (click)="setSlide(slide)" [ngClass]="getDotClass(slide)"></span>
}
</div>
[style.background-image]="'url(' + slide + ')'"
[style.opacity]="carouselOpacity"
[style.display]="'block'"></span>
</div>
</div>
</div>
</section>
<section style="background: #202020;">
<div class="customContainer">
<h2 id="quote" style="color: white; font-family: 'minecraft-text',sans-serif; line-height: 1.3em;">"Great
community, great people, great server all round . . . If it can bring back my love for minecraft, it could
work wonders for you."</h2>
<p style="color: white; margin-top:30px;">- /u/seanhanley1993</p>
</div>
</section>
<section class="darkmodeSection">
<div class="customContainer">
<img ngSrc="/public/img/logos/log.png" alt="Alternative Altitude Server Logo" height="129"
width="120">
<h2 style="margin: 20px 0; font-size: 1.8em; padding-bottom: 0 !important;">play.alttd.com</h2>
<app-copy-ip></app-copy-ip>
<div class="dots">
@for (slide of getSlideIndices(); track slide) {
<span class="dot" (click)="setSlide(slide)" [ngClass]="getDotClass(slide)"></span>
}
</div>
</section>
<a (click)="this.scrollService.scrollToTop()" class="scroll-up-button, active">
<span></span>
<p style="display: none;">Scroll Down</p>
</a>
</main>
</ng-container>
</div>
</div>
</div>
</section>
<section style="background: #202020;">
<div class="customContainer">
<h2 id="quote" style="color: white; font-family: 'minecraft-text',sans-serif; line-height: 1.3em;">"Great
community, great people, great server all round . . . If it can bring back my love for minecraft, it could
work wonders for you."</h2>
<p style="color: white; margin-top:30px;">- /u/seanhanley1993</p>
</div>
</section>
<section class="darkmodeSection">
<div class="customContainer">
<img ngSrc="/public/img/logos/log.png" alt="Alternative Altitude Server Logo" height="129"
width="120">
<h2 style="margin: 20px 0; font-size: 1.8em; padding-bottom: 0 !important;">play.alttd.com</h2>
<app-copy-ip></app-copy-ip>
</div>
</section>
<a (click)="this.scrollService.scrollToTop()" class="scroll-up-button, active">
<span></span>
<p style="display: none;">Scroll Down</p>
</a>
</main>
</ng-container>
+36 -5
View File
@@ -1,12 +1,22 @@
import {Component, OnInit} from '@angular/core';
import {Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
import {Title} from '@angular/platform-browser';
import {ALTITUDE_VERSION} from '@custom-types/constant';
import {ScrollService} from '@services/scroll.service';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import {CommonModule, NgOptimizedImage} from '@angular/common';
import {HeaderComponent} from '@header/header.component';
import {CopyIpComponent} from '@shared-components/copy-ip/copy-ip.component';
import {RouterLink} from '@angular/router';
import {JwtHelperService} from '@auth0/angular-jwt';
import {interval, map} from 'rxjs';
import {HttpClient} from '@angular/common/http';
import {startWith} from 'rxjs/operators';
interface MinecraftServerStatus {
online?: boolean;
players?: {
online?: number;
};
}
@Component({
standalone: true,
@@ -24,9 +34,16 @@ import {JwtHelperService} from '@auth0/angular-jwt';
templateUrl: './home.component.html',
styleUrl: './home.component.scss'
})
export class HomeComponent implements OnInit {
constructor(private titleService: Title, public scrollService: ScrollService) {
}
export class HomeComponent implements OnInit, OnDestroy {
private httpClient = inject(HttpClient);
private titleService = inject(Title);
public scrollService = inject(ScrollService);
public playerCount = signal<null | number>(null);
private playerCountUpdateInterval = interval(60000).pipe(
startWith(() => 0),
).subscribe(() => {
this.updatePlayerCount();
});
private slides: string[] = ["/public/img/backgrounds/caruselimage2.png",
"/public/img/backgrounds/caruselimage4.png",
@@ -44,6 +61,10 @@ export class HomeComponent implements OnInit {
this.randomizeSlides()
}
ngOnDestroy(): void {
this.playerCountUpdateInterval.unsubscribe();
}
private randomizeSlides(): void {
const array = [...this.slides];
@@ -108,5 +129,15 @@ export class HomeComponent implements OnInit {
}
}
private updatePlayerCount() {
this.httpClient.get<MinecraftServerStatus>('https://api.mcsrvstat.us/2/play.alttd.com').pipe(
map(response => {
if (response.online && response.players && response.players.online) {
this.playerCount.set(response.players.online);
}
})
).subscribe();
}
protected readonly ALTITUDE_VERSION = ALTITUDE_VERSION;
}
@@ -0,0 +1,39 @@
<h2 mat-dialog-title>Edit {{ data.punishment.type }} #{{ data.punishment.id }}</h2>
<div mat-dialog-content>
<div class="dialog-content">
<mat-form-field appearance="fill">
<mat-label>Reason</mat-label>
<input matInput type="text" [(ngModel)]="reason" placeholder="Enter reason"/>
</mat-form-field>
<mat-checkbox [(ngModel)]="isPermanent"><span style="color: black">Permanent</span></mat-checkbox>
@if (!isPermanent) {
<div class="datetime-container">
<mat-form-field appearance="fill" class="date-field">
<mat-label>Expiry Date</mat-label>
<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()) {
<div class="error">{{ errorMessage() }}</div>
}
</div>
</div>
<div mat-dialog-actions align="end">
<button mat-button color="warn" (click)="onRemove()" [disabled]="isBusy()">Remove</button>
<button mat-raised-button color="primary" (click)="onUpdate()" [disabled]="isBusy()">Update</button>
<button mat-button (click)="onCancel()" [disabled]="isBusy()">Cancel</button>
</div>
@@ -0,0 +1,30 @@
.dialog-content {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.datetime-container {
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;
}
@@ -0,0 +1,165 @@
import {Component, Inject, inject, signal} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInput, MatLabel} from '@angular/material/input';
import {MatCheckboxModule} from '@angular/material/checkbox';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle
} from '@angular/material/dialog';
import {HistoryService, PunishmentHistory} from '@api';
import {firstValueFrom} from 'rxjs';
import {MatDatepickerModule} from '@angular/material/datepicker';
interface EditPunishmentData {
punishment: PunishmentHistory;
}
@Component({
selector: 'app-edit-punishment-dialog',
standalone: true,
imports: [
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInput,
MatLabel,
MatCheckboxModule,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDatepickerModule,
],
templateUrl: './edit-punishment-dialog.component.html',
styleUrl: './edit-punishment-dialog.component.scss'
})
export class EditPunishmentDialogComponent {
// Form model
protected reason: string = '';
protected isPermanent: boolean = false;
protected expiresAt: Date;
protected expiryDate: Date | null = null;
protected expiryTime: string = '';
// UI state
protected isBusy = signal<boolean>(false);
protected errorMessage = signal<string | null>(null);
private historyApi = inject(HistoryService);
constructor(
public dialogRef: MatDialogRef<EditPunishmentDialogComponent, PunishmentHistory | { removed: true } | null>,
@Inject(MAT_DIALOG_DATA) public data: EditPunishmentData
) {
const punishment = data.punishment;
this.reason = punishment.reason ?? '';
this.isPermanent = punishment.expiryTime <= 0;
this.expiresAt = new Date(punishment.expiryTime);
if (this.expiresAt && !isNaN(this.expiresAt.getTime())) {
this.expiryDate = new Date(this.expiresAt);
const hours = this.expiresAt.getHours().toString().padStart(2, '0');
const minutes = this.expiresAt.getMinutes().toString().padStart(2, '0');
this.expiryTime = `${hours}:${minutes}`;
}
}
onCancel(): void {
if (this.isBusy()) {
return;
}
this.dialogRef.close(null);
}
private computeUntilMs(): number {
if (this.isPermanent) {
return 0;
}
if (!this.expiryDate) {
return 0;
}
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;
}
onUpdate(): void {
const punishment = this.data.punishment;
if (!window.confirm('Are you sure you want to update this punishment?')) {
return;
}
this.isBusy.set(true);
this.errorMessage.set(null);
const updates: Array<Promise<PunishmentHistory>> = [] as any;
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)));
}
const newUntil = this.computeUntilMs();
if (newUntil !== punishment.expiryTime) {
console.log('Changing until to ', newUntil, ' from ', punishment.expiryTime, '')
updates.push(firstValueFrom(this.historyApi.updatePunishmentUntil(punishment.type, punishment.id, newUntil)));
}
if (updates.length === 0) {
this.isBusy.set(false);
this.dialogRef.close(null);
return;
}
Promise.all(updates)
.then(results => {
const updated = results[results.length - 1];
this.isBusy.set(false);
this.dialogRef.close(updated);
})
.catch(err => {
console.error(err);
this.errorMessage.set('Failed to update punishment');
this.isBusy.set(false);
});
}
onRemove(): void {
const punishment = this.data.punishment;
if (!window.confirm('Are you sure you want to remove this punishment?')) {
return;
}
this.isBusy.set(true);
this.errorMessage.set(null);
this.historyApi.removePunishment(punishment.type as any, punishment.id).subscribe({
next: () => {
this.isBusy.set(false);
this.dialogRef.close({removed: true});
},
error: (err) => {
console.error(err);
this.errorMessage.set('Failed to remove punishment');
this.isBusy.set(false);
}
})
}
getTimezone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'Unknown';
}
}
@@ -60,7 +60,7 @@ export class HistoryFormatService {
public getAvatarUrl(entry: string, size: string = '25'): string {
let uuid = entry.replace('-', '');
if (uuid === 'C') {
if (uuid === 'C' || uuid === 'Console' || uuid === '[Console]') {
uuid = "f78a4d8dd51b4b3998a3230f2de0c670"
}
return `https://crafatar.com/avatars/${uuid}?size=${size}&overlay`;
@@ -6,14 +6,17 @@
<table [cellSpacing]="0">
<div class="historyTableHead">
<thead>
<tr>
<th class="historyType">Type</th>
<th class="historyPlayer">Player</th>
<th class="historyPlayer">Banned By</th>
<th class="historyReason">Reason</th>
<th class="historyDate">Date</th>
<th class="historyDate">Expires</th>
</tr>
<tr>
<th class="historyType">Type</th>
<th class="historyPlayer">Player</th>
<th class="historyPlayer">Banned By</th>
<th class="historyReason">Reason</th>
<th class="historyDate">Date</th>
<th class="historyDate">Expires</th>
@if (canEdit()) {
<th class="historyActions"></th>
}
</tr>
</thead>
</div>
<div>
@@ -26,30 +29,36 @@
<td class="historyPlayer" (click)="setSearch(entry.username, 'player')">
<div class="playerContainer">
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.uuid)" width="25" height="25"
alt="{{entry.username}}'s Minecraft skin">
<span class="username">{{ entry.username }}</span>
</div>
alt="{{entry.username}}'s Minecraft skin">
<span class="username">{{ entry.username }}</span>
</div>
</td>
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
<div class="playerContainer">
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25"
height="25"
alt="{{entry.punishedBy}}'s Minecraft skin">
<span>{{ entry.punishedBy }}</span>
</div>
</td>
<td class="historyReason" (click)="showDetailedPunishment(entry)">
{{ entry.reason | removeTrailingPeriod }}
</td>
<td class="historyDate" (click)="showDetailedPunishment(entry)">
{{ this.historyFormat.getPunishmentTime(entry) }}
</td>
<td class="historyDate" (click)="showDetailedPunishment(entry)">
{{ this.historyFormat.getExpiredTime(entry) }}
</td>
@if (canEdit()) {
<td class="historyActions" (click)="openEdit(entry)">
<mat-icon>edit</mat-icon>
</td>
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
<div class="playerContainer">
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25" height="25"
alt="{{entry.punishedBy}}'s Minecraft skin">
<span>{{ entry.punishedBy }}</span>
</div>
</td>
<td class="historyReason" (click)="showDetailedPunishment(entry)">
{{ entry.reason | removeTrailingPeriod }}
</td>
<td class="historyDate" (click)="showDetailedPunishment(entry)">
{{ this.historyFormat.getPunishmentTime(entry) }}
</td>
<td class="historyDate" (click)="showDetailedPunishment(entry)">
{{ this.historyFormat.getExpiredTime(entry) }}
</td>
</tr>
}
</tbody>
</div>
</table>
}
</tr>
}
</tbody>
</div>
</table>
}
@@ -111,3 +111,8 @@ img {
.historyDate {
width: 170px;
}
.historyActions {
width: 100px;
padding: 0 10px 0 10px;
}
@@ -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 {catchError, map, Observable, shareReplay} from 'rxjs';
import {NgOptimizedImage} from '@angular/common';
@@ -8,12 +8,17 @@ import {HttpErrorResponse} from '@angular/common/http';
import {HistoryFormatService} from '../history-format.service';
import {SearchParams} from '../search-terms';
import {Router} from '@angular/router';
import {AuthService} from '@services/auth.service';
import {MatDialog} from '@angular/material/dialog';
import {EditPunishmentDialogComponent} from '../edit-punishment-dialog/edit-punishment-dialog.component';
import {MatIconModule} from '@angular/material/icon';
@Component({
selector: 'app-history',
imports: [
NgOptimizedImage,
RemoveTrailingPeriodPipe
RemoveTrailingPeriodPipe,
MatIconModule,
],
templateUrl: './history.component.html',
styleUrl: './history.component.scss',
@@ -35,8 +40,11 @@ export class HistoryComponent implements OnInit, OnChanges {
public history: PunishmentHistory[] = []
constructor(private historyApi: HistoryService, public historyFormat: HistoryFormatService, private router: Router) {
}
private historyApi: HistoryService = inject(HistoryService)
public historyFormat: HistoryFormatService = inject(HistoryFormatService)
private router: Router = inject(Router)
private authService: AuthService = inject(AuthService)
private dialog: MatDialog = inject(MatDialog)
ngOnChanges(): void {
this.reloadHistory();
@@ -101,4 +109,21 @@ export class HistoryComponent implements OnInit, OnChanges {
public showDetailedPunishment(entry: PunishmentHistory) {
this.router.navigate([`bans/${entry.type}/${entry.id}`]).then();
}
public canEdit(): boolean {
return this.authService.hasAccess(['SCOPE_head_mod']);
}
public openEdit(punishment: PunishmentHistory) {
if (!this.canEdit()) return;
const ref = this.dialog.open(EditPunishmentDialogComponent, {
data: {punishment},
width: '500px',
});
ref.afterClosed().subscribe(result => {
if (result) {
this.reloadHistory();
}
});
}
}
+19 -70
View File
@@ -41,78 +41,27 @@
</section>
</section>
<section class="voteSection">
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
<div class="vote">
<h2>MinecraftServers</h2>
<div>
<a onclick="clickVote('vote1');" oncontextmenu="clickVote('vote1');" target="_blank" rel="noopener"
href="https://minecraftservers.org/vote/284208">
<div class="button-outer">
<span id="vote1" class="button-inner">Vote!</span>
<div class="container voteContainer">
@for (voteSite of Object.keys(voteSites); track voteSite) {
<div class="vote">
<h2>{{ voteSite }}</h2>
<div>
<a (click)="clickVote(voteSite)" (contextmenu)="clickVote(voteSite); $event.preventDefault()"
target="_blank" rel="noopener"
[href]="voteSites[voteSite]">
<div class=button-outer [class.not-available-button-outer]="!canVote(voteSite)"
[class.available-button-outer]="canVote(voteSite)">
<span class="button-inner">{{ getVoteText(voteSite) }}</span>
</div>
</a>
</div>
@if (voteStats) {
<div class="voteStats">
<p>Last voted: {{ getLastVoted(voteSite) | TimeAgo: true }}</p>
</div>
</a>
}
</div>
</div>
<div class="vote">
<h2>TopMinecraftServers</h2>
<div>
<a (click)="clickVote('vote2')" oncontextmenu="clickVote('vote2'); return false;" target="_blank"
rel="noopener"
href="https://topminecraftservers.org/vote/4906">
<div class="button-outer">
<span id="vote2" class="button-inner">Vote!</span>
</div>
</a>
</div>
</div>
<div class="vote">
<h2>MCSL</h2>
<div>
<a (click)="clickVote('vote3')" oncontextmenu="clickVote('vote3'); return false;" target="_blank"
rel="noopener"
href="https://minecraft-server-list.com/server/298238/vote/">
<div class="button-outer">
<span id="vote3" class="button-inner">Vote!</span>
</div>
</a>
</div>
</div>
<div class="vote">
<h2>Minecraft-Server</h2>
<div>
<a (click)="clickVote('vote4')" oncontextmenu="clickVote('vote4'); return false;" target="_blank"
rel="noopener"
href="https://minecraft-server.net/vote/Altitude/">
<div class="button-outer">
<span id="vote4" class="button-inner">Vote!</span>
</div>
</a>
</div>
</div>
<div class="vote">
<h2>PlanetMinecraft</h2>
<div>
<a (click)="clickVote('vote5')" oncontextmenu="clickVote('vote5'); return false;" target="_blank"
rel="noopener"
href="https://www.planetminecraft.com/server/alttd/vote/">
<div class="button-outer">
<span id="vote5" class="button-inner">Vote!</span>
</div>
</a>
</div>
</div>
<div class="vote">
<h2>Minecraft-MP</h2>
<div>
<a (click)="clickVote('vote6')" oncontextmenu="clickVote('vote6'); return false;" target="_blank"
rel="noopener"
href="https://minecraft-mp.com/server/98955/vote/">
<div class="button-outer">
<span id="vote6" class="button-inner">Vote!</span>
</div>
</a>
</div>
</div>
}
</div>
</section>
<section class="darkmodeSection">
@@ -4,6 +4,11 @@
margin: 0 auto;
}
.voteContainer {
padding: 50px 0 0 0;
justify-content: center;
}
.voteSection {
background-color: var(--link-color);
transition: 0.5s ease;
@@ -36,3 +41,11 @@
color: black;
text-shadow: none;
}
.available-button-outer {
background-color: #4caf50 !important;
}
.not-available-button-outer {
background-color: var(--white) !important;
}
+85 -7
View File
@@ -1,24 +1,102 @@
import {Component} from '@angular/core';
import {Component, effect, inject, OnDestroy, OnInit} from '@angular/core';
import {ScrollService} from '@services/scroll.service';
import {HeaderComponent} from '@header/header.component';
import {SiteService, VoteData} from '@api';
import {AuthService} from '@services/auth.service';
import {interval, Subscription} from 'rxjs';
import {TimeAgoPipe} from '@pipes/TimeAgoPipe';
@Component({
selector: 'app-vote',
standalone: true,
imports: [
HeaderComponent
],
HeaderComponent,
TimeAgoPipe
],
templateUrl: './vote.component.html',
styleUrl: './vote.component.scss'
})
export class VoteComponent {
constructor(public scrollService: ScrollService) {
export class VoteComponent implements OnInit, OnDestroy {
private readonly defaultVoteMessage = 'Vote!';
private readonly clickedVoteMessage = 'Clicked!';
private voteMessages: { [key: string]: string } = {}
private refreshSubscription: Subscription | null = null;
protected readonly voteSites: { [key: string]: string } = {
'PlanetMinecraft': 'https://www.planetminecraft.com/server/alttd/vote/',
'TopMinecraftServers': 'https://topminecraftservers.org/vote/4906',
'Minecraft-Server': 'https://minecraft-server.net/vote/Altitude/',
'MinecraftServers': 'https://minecraftservers.org/vote/284208',
'MCSL': 'https://minecraft-server-list.com/server/298238/vote/',
'Minecraft-MP': 'https://minecraft-mp.com/server/98955/vote/',
}
voteMessage: string = '';
protected scrollService: ScrollService = inject(ScrollService);
protected siteService = inject(SiteService)
protected authService = inject(AuthService)
protected voteStats: VoteData | null = null
constructor() {
effect(() => {
if (this.authService.isAuthenticated$()) {
this.loadVoteStats();
}
});
}
ngOnInit(): void {
this.refreshSubscription = interval(300000).subscribe(() => {
this.loadVoteStats();
});
}
ngOnDestroy(): void {
this.refreshSubscription?.unsubscribe();
}
clickVote(id: string) {
this.voteMessage = 'Clicked!';
this.voteMessages[id] = this.clickedVoteMessage;
}
getVoteText(id: string) {
return this.voteMessages[id] || this.defaultVoteMessage;
}
private loadVoteStats(): void {
if (!this.authService.isAuthenticated$()) {
return
}
this.siteService.getVoteStats().subscribe(voteStats => {
this.voteStats = voteStats;
});
}
protected getLastVoted(id: string): Date | null {
if (!this.voteStats) {
return null;
}
const filteredVoteInfo = this.voteStats.allVoteInfo
.filter(voteInfo => voteInfo.siteName === id);
if (filteredVoteInfo.length !== 1) {
return null;
}
return new Date(filteredVoteInfo[0].lastVoteTimestamp);
}
protected readonly Object = Object;
canVote(voteSite: string) {
if (!this.voteStats) {
return false;
}
const now: Date = new Date();
return (
this.voteStats.allVoteInfo.some(voteInfo => voteInfo.siteName === voteSite
&& voteInfo.lastVoteTimestamp - now.getTime() < 86400000)
)
}
}
+55
View File
@@ -0,0 +1,55 @@
import {Pipe, PipeTransform} from '@angular/core';
@Pipe({
name: 'TimeAgo',
standalone: true
})
export class TimeAgoPipe implements PipeTransform {
transform(value: Date | string | number | null, short?: boolean): string {
if (!value) {
return '';
}
const date = new Date(value);
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (seconds < 60) {
return 'just now';
}
let returnText = 'ago';
const allMinutes = Math.floor(seconds / 60);
const minutes = allMinutes % 60;
if (short) {
returnText = `${minutes}m ${returnText}`
} else {
returnText = `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ${returnText}`
}
if (allMinutes < 60) {
return returnText
}
const allHours = Math.floor(allMinutes / 60);
const hours = allHours % 24;
if (short) {
returnText = `${hours}h ${returnText}`
} else {
returnText = `${hours} ${hours === 1 ? 'hour' : 'hours'} ${returnText}`
}
if (allHours < 24) {
return returnText
}
const days = Math.floor(allHours / 24);
if (short) {
returnText = `${days}d ${returnText}`
} else {
returnText = `${days} ${days === 1 ? 'day' : 'days'} ${returnText}`
}
return returnText
}
}
+1 -3
View File
@@ -68,7 +68,7 @@ export class AuthService {
/**
* Check if the user is authenticated
*/
public checkAuthStatus(): boolean {
private checkAuthStatus(): boolean {
const jwt = this.getJwt();
if (!jwt) {
console.log("No JWT found");
@@ -82,7 +82,6 @@ export class AuthService {
}
const claims = this.extractJwtClaims(jwt);
console.log("User claims: ", claims);
this.userClaimsSubject.set(claims);
this.isAuthenticatedSubject.set(true);
if (this.username() == null) {
@@ -109,7 +108,6 @@ export class AuthService {
localStorage.setItem('jwt', jwt);
const claims = this.extractJwtClaims(jwt);
console.log("Saving user claims: ", claims);
this.userClaimsSubject.set(claims);
}
+2
View File
@@ -4,6 +4,7 @@ import {provideRouter} from '@angular/router';
import {routes} from './app/app.routes';
import {provideHttpClient, withInterceptors} from '@angular/common/http';
import {authInterceptor} from '@services/AuthInterceptor';
import {provideNativeDateAdapter} from '@angular/material/core';
bootstrapApplication(AppComponent, {
providers: [
@@ -12,6 +13,7 @@ bootstrapApplication(AppComponent, {
provideHttpClient(
withInterceptors([authInterceptor])
),
provideNativeDateAdapter()
]
}).catch(err => console.error(err));
+9
View File
@@ -350,6 +350,15 @@ main .container {
background: #8b8a8f;
}
.checkbox-field mat-checkbox {
color: var(--font-color);
}
.mat-mdc-checkbox .mdc-form-field label {
color: var(--font-color);
}
// Darkmode sections
.darkmodeSection {
background-color: var(--color-secondary);
+12
View File
@@ -28,6 +28,8 @@ tags:
description: All action related to appeals
- name: mail
description: All actions related to user email verification
- name: site
description: Actions related to small features on the site such as displaying vote stats or pt/rank stats
paths:
/api/team/{team}:
$ref: './schemas/team/team.yml#/getTeam'
@@ -49,6 +51,12 @@ paths:
$ref: './schemas/bans/bans.yml#/getAllHistoryForUUID'
/api/history/total:
$ref: './schemas/bans/bans.yml#/getTotalPunishments'
/api/history/admin/{type}/{id}/reason:
$ref: './schemas/bans/bans.yml#/updatePunishmentReason'
/api/history/admin/{type}/{id}/until:
$ref: './schemas/bans/bans.yml#/updatePunishmentUntil'
/api/history/admin/{type}/{id}:
$ref: './schemas/bans/bans.yml#/removePunishment'
/api/appeal/update-mail:
$ref: './schemas/forms/appeal/appeal.yml#/UpdateMail'
/api/appeal/minecraft-appeal:
@@ -57,6 +65,8 @@ paths:
$ref: './schemas/forms/appeal/appeal.yml#/DiscordAppeal'
/api/apply/staff-application:
$ref: './schemas/forms/staff_apply/staff_apply.yml#/StaffApply'
/api/apply/staff-application-is-open:
$ref: './schemas/forms/staff_apply/staff_apply.yml#/StaffApplicationsIsOpen'
/api/login/requestNewUserLogin/{uuid}:
$ref: './schemas/login/login.yml#/RequestNewUserLogin'
/api/login/userLogin/{code}:
@@ -81,3 +91,5 @@ paths:
$ref: './schemas/forms/mail/mail.yml#/DeleteEmail'
/api/mail/list:
$ref: './schemas/forms/mail/mail.yml#/GetEmails'
/api/site/vote:
$ref: './schemas/site/vote.yml#/VoteStats'
@@ -211,6 +211,73 @@ getAllHistoryForUUID:
application/json:
schema:
$ref: '#/components/schemas/PunishmentHistoryList'
updatePunishmentReason:
patch:
tags:
- history
summary: Update punishment reason
description: Updates the reason for a specific punishment history entry
operationId: updatePunishmentReason
parameters:
- $ref: '#/components/parameters/HistoryType'
- $ref: '#/components/parameters/Id'
- $ref: '#/components/parameters/NewPunishmentReason'
responses:
'200':
description: Updated punishment
content:
application/json:
schema:
$ref: '#/components/schemas/PunishmentHistory'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
updatePunishmentUntil:
patch:
tags:
- history
summary: Update punishment expiry time
description: Updates the expiry time (until) for a specific punishment history entry (only for ban and mute)
operationId: updatePunishmentUntil
parameters:
- $ref: '#/components/parameters/HistoryType'
- $ref: '#/components/parameters/Id'
- $ref: '#/components/parameters/NewUntil'
responses:
'200':
description: Updated punishment
content:
application/json:
schema:
$ref: '#/components/schemas/PunishmentHistory'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
removePunishment:
delete:
tags:
- history
summary: Remove punishment
description: Removes a punishment from history. For bans and mutes, the punishment is deactivated; for kicks and warnings, the row is deleted.
operationId: removePunishment
parameters:
- $ref: '#/components/parameters/HistoryType'
- $ref: '#/components/parameters/Id'
responses:
'204':
description: Punishment removed
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
components:
parameters:
HistoryType:
@@ -250,6 +317,21 @@ components:
schema:
type: integer
description: The id of the punishment that should be retrieved
NewPunishmentReason:
name: reason
in: query
required: true
schema:
type: string
description: The new reason to set for the punishment
NewUntil:
name: until
in: query
required: true
schema:
type: integer
format: int64
description: The new expiry time (epoch millis)
schemas:
SearchResults:
type: integer
@@ -24,6 +24,28 @@ StaffApply:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
StaffApplicationsIsOpen:
get:
tags:
- applications
summary: Get if staff applications are open
description: Get if staff applications are open
operationId: getStaffApplicationsIsOpen
responses:
'200':
description: If staff applications are open
content:
application/json:
schema:
type: boolean
'403':
description: If staff applications are not open
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
components:
schemas:
StaffApplication:
@@ -66,7 +88,6 @@ components:
description: Preferred pronouns of the applicant
joinDate:
type: string
maxLength: 256
format: date
description: Date when the applicant joined the service
weeklyPlaytime:
@@ -0,0 +1,81 @@
VoteStats:
get:
tags:
- site
summary: Get vote stats
description: Get vote stats for current user
operationId: getVoteStats
responses:
'200':
description: Vote stats retrieved
content:
application/json:
schema:
$ref: '#/components/schemas/VoteData'
components:
schemas:
VoteData:
type: object
required:
- allVoteInfo
- voteStats
- voteStreak
- bestVoteStreak
properties:
allVoteInfo:
type: array
items:
$ref: '#/components/schemas/VoteInfo'
voteStats:
$ref: '#/components/schemas/VoteStats'
voteStreak:
$ref: '#/components/schemas/VoteStreak'
bestVoteStreak:
$ref: '#/components/schemas/VoteStreak'
VoteInfo:
type: object
required:
- siteName
- lastVoteTimestamp
properties:
siteName:
type: string
lastVoteTimestamp:
type: integer
format: int64
VoteStats:
type: object
required:
- total
- monthly
- weekly
- daily
properties:
total:
type: integer
format: int32
monthly:
type: integer
format: int32
weekly:
type: integer
format: int32
daily:
type: integer
format: int32
VoteStreak:
type: object
required:
- dailyStreak
- weeklyStreak
- monthlyStreak
properties:
dailyStreak:
type: integer
format: int32
weeklyStreak:
type: integer
format: int32
monthlyStreak:
type: integer
format: int32