Add account deletion, data requests, and more.
This commit is contained in:
parent
7a31ab5028
commit
9d1712889e
|
@ -1,10 +1,14 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.api.dao.submission;
|
package nl.andrewlalis.gymboard_api.domains.api.dao.submission;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.api.model.submission.Submission;
|
import nl.andrewlalis.gymboard_api.domains.api.model.submission.Submission;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface SubmissionRepository extends JpaRepository<Submission, String>, JpaSpecificationExecutor<Submission> {
|
public interface SubmissionRepository extends JpaRepository<Submission, String>, JpaSpecificationExecutor<Submission> {
|
||||||
|
@Modifying
|
||||||
|
void deleteAllByUser(User user);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,9 @@ import nl.andrewlalis.gymboard_api.domains.auth.dto.*;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.Role;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.Role;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.UserPreferences;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.UserPreferences;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.service.DataRequestService;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService;
|
import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccessService;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.service.UserAccountDeletionService;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.service.UserService;
|
import nl.andrewlalis.gymboard_api.domains.auth.service.UserService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
@ -12,16 +14,19 @@ import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
public class UserController {
|
public class UserController {
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final DataRequestService dataRequestService;
|
||||||
|
private final UserAccountDeletionService accountDeletionService;
|
||||||
private final UserAccessService userAccessService;
|
private final UserAccessService userAccessService;
|
||||||
|
|
||||||
public UserController(UserService userService, UserAccessService userAccessService) {
|
public UserController(UserService userService, DataRequestService dataRequestService, UserAccountDeletionService accountDeletionService, UserAccessService userAccessService) {
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
|
this.dataRequestService = dataRequestService;
|
||||||
|
this.accountDeletionService = accountDeletionService;
|
||||||
this.userAccessService = userAccessService;
|
this.userAccessService = userAccessService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -165,4 +170,16 @@ public class UserController {
|
||||||
public List<String> getMyRoles(@AuthenticationPrincipal User myUser) {
|
public List<String> getMyRoles(@AuthenticationPrincipal User myUser) {
|
||||||
return myUser.getRoles().stream().map(Role::getShortName).toList();
|
return myUser.getRoles().stream().map(Role::getShortName).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(path = "/auth/me/data-requests")
|
||||||
|
public ResponseEntity<Void> requestData(@AuthenticationPrincipal User myUser) {
|
||||||
|
dataRequestService.createRequest(myUser.getId());
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping(path = "/auth/me")
|
||||||
|
public ResponseEntity<Void> deleteAccount(@AuthenticationPrincipal User myUser) {
|
||||||
|
accountDeletionService.deleteAccount(myUser);
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.EmailResetCode;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.EmailResetCode;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
@ -11,4 +12,10 @@ import java.time.LocalDateTime;
|
||||||
public interface EmailResetCodeRepository extends JpaRepository<EmailResetCode, String> {
|
public interface EmailResetCodeRepository extends JpaRepository<EmailResetCode, String> {
|
||||||
@Modifying
|
@Modifying
|
||||||
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
|
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
|
||||||
|
|
||||||
|
boolean existsByNewEmail(String newEmail);
|
||||||
|
@Modifying
|
||||||
|
void deleteByNewEmail(String newEmail);
|
||||||
|
@Modifying
|
||||||
|
void deleteAllByUser(User user);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.PasswordResetCode;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.PasswordResetCode;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
@ -11,4 +12,6 @@ import java.time.LocalDateTime;
|
||||||
public interface PasswordResetCodeRepository extends JpaRepository<PasswordResetCode, String> {
|
public interface PasswordResetCodeRepository extends JpaRepository<PasswordResetCode, String> {
|
||||||
@Modifying
|
@Modifying
|
||||||
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
|
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
|
||||||
|
@Modifying
|
||||||
|
void deleteAllByUser(User user);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||||
|
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.UserAccountDataRequest;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface UserAccountDataRequestRepository extends JpaRepository<UserAccountDataRequest, Long> {
|
||||||
|
boolean existsByUserIdAndFulfilledFalse(String userId);
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||||
|
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
@ -14,4 +15,6 @@ public interface UserActivationCodeRepository extends JpaRepository<UserActivati
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
|
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
|
||||||
|
@Modifying
|
||||||
|
void deleteAllByUser(User user);
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,8 @@ public interface UserFollowingRepository extends JpaRepository<UserFollowing, Lo
|
||||||
|
|
||||||
@Modifying
|
@Modifying
|
||||||
void deleteByFollowedUserAndFollowingUser(User followedUser, User followingUser);
|
void deleteByFollowedUserAndFollowingUser(User followedUser, User followingUser);
|
||||||
|
@Modifying
|
||||||
|
void deleteAllByFollowedUserOrFollowingUser(User followedUser, User followingUser);
|
||||||
|
|
||||||
Page<UserFollowing> findAllByFollowedUserOrderByCreatedAtDesc(User followedUser, Pageable pageable);
|
Page<UserFollowing> findAllByFollowedUserOrderByCreatedAtDesc(User followedUser, Pageable pageable);
|
||||||
Page<UserFollowing> findAllByFollowingUserOrderByCreatedAtDesc(User followingUser, Pageable pageable);
|
Page<UserFollowing> findAllByFollowingUserOrderByCreatedAtDesc(User followingUser, Pageable pageable);
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||||
|
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.UserReport;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.UserReport;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface UserReportRepository extends JpaRepository<UserReport, Long> {
|
public interface UserReportRepository extends JpaRepository<UserReport, Long> {
|
||||||
|
@Modifying
|
||||||
|
void deleteAllByUserOrReportedBy(User user, User reportedBy);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
package nl.andrewlalis.gymboard_api.domains.auth.dao;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
|
@ -18,4 +21,7 @@ public interface UserRepository extends JpaRepository<User, String>, JpaSpecific
|
||||||
|
|
||||||
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email")
|
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email")
|
||||||
Optional<User> findByEmailWithRoles(String email);
|
Optional<User> findByEmailWithRoles(String email);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
void deleteAllByActivatedFalseAndCreatedAtBefore(LocalDateTime cutoff);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import java.time.LocalDateTime;
|
||||||
* A code that's sent to a user's new email address to confirm that they own
|
* A code that's sent to a user's new email address to confirm that they own
|
||||||
* it. Once confirmed, the user's email address will be updated.
|
* it. Once confirmed, the user's email address will be updated.
|
||||||
*/
|
*/
|
||||||
@Table(name = "auth_email_reset_code")
|
@Table(name = "auth_user_email_reset_code")
|
||||||
@Entity
|
@Entity
|
||||||
public class EmailResetCode {
|
public class EmailResetCode {
|
||||||
public static final Duration VALID_FOR = Duration.ofMinutes(30);
|
public static final Duration VALID_FOR = Duration.ofMinutes(30);
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.auth.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A request issued by a user for a download of their entire account data set.
|
||||||
|
* This entity is created when a user sends a request, and will get picked up
|
||||||
|
* and processed eventually by a scheduled task, and ultimately the user will be
|
||||||
|
* sent an email with a link to download their data.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "auth_user_account_data_request")
|
||||||
|
public class UserAccountDataRequest {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean fulfilled = false;
|
||||||
|
|
||||||
|
public UserAccountDataRequest() {}
|
||||||
|
|
||||||
|
public UserAccountDataRequest(User user) {
|
||||||
|
this.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDateTime getCreatedAt() {
|
||||||
|
return createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isFulfilled() {
|
||||||
|
return fulfilled;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,6 @@
|
||||||
package nl.andrewlalis.gymboard_api.domains.auth.service;
|
package nl.andrewlalis.gymboard_api.domains.auth.service;
|
||||||
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.EmailResetCodeRepository;
|
import nl.andrewlalis.gymboard_api.domains.auth.dao.*;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.PasswordResetCodeRepository;
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserActivationCodeRepository;
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserFollowRequestRepository;
|
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.EmailResetCode;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.EmailResetCode;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.PasswordResetCode;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.PasswordResetCode;
|
||||||
import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode;
|
import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode;
|
||||||
|
@ -21,12 +18,20 @@ public class CleanupService {
|
||||||
private final UserActivationCodeRepository activationCodeRepository;
|
private final UserActivationCodeRepository activationCodeRepository;
|
||||||
private final UserFollowRequestRepository followRequestRepository;
|
private final UserFollowRequestRepository followRequestRepository;
|
||||||
private final EmailResetCodeRepository emailResetCodeRepository;
|
private final EmailResetCodeRepository emailResetCodeRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
public CleanupService(PasswordResetCodeRepository passwordResetCodeRepository, UserActivationCodeRepository activationCodeRepository, UserFollowRequestRepository followRequestRepository, EmailResetCodeRepository emailResetCodeRepository) {
|
public CleanupService(
|
||||||
|
PasswordResetCodeRepository passwordResetCodeRepository,
|
||||||
|
UserActivationCodeRepository activationCodeRepository,
|
||||||
|
UserFollowRequestRepository followRequestRepository,
|
||||||
|
EmailResetCodeRepository emailResetCodeRepository,
|
||||||
|
UserRepository userRepository
|
||||||
|
) {
|
||||||
this.passwordResetCodeRepository = passwordResetCodeRepository;
|
this.passwordResetCodeRepository = passwordResetCodeRepository;
|
||||||
this.activationCodeRepository = activationCodeRepository;
|
this.activationCodeRepository = activationCodeRepository;
|
||||||
this.followRequestRepository = followRequestRepository;
|
this.followRequestRepository = followRequestRepository;
|
||||||
this.emailResetCodeRepository = emailResetCodeRepository;
|
this.emailResetCodeRepository = emailResetCodeRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -44,5 +49,7 @@ public class CleanupService {
|
||||||
followRequestRepository.deleteAllByCreatedAtBefore(followRequestCutoff);
|
followRequestRepository.deleteAllByCreatedAtBefore(followRequestCutoff);
|
||||||
LocalDateTime emailResetCodeCutoff = LocalDateTime.now().minus(EmailResetCode.VALID_FOR);
|
LocalDateTime emailResetCodeCutoff = LocalDateTime.now().minus(EmailResetCode.VALID_FOR);
|
||||||
emailResetCodeRepository.deleteAllByCreatedAtBefore(emailResetCodeCutoff);
|
emailResetCodeRepository.deleteAllByCreatedAtBefore(emailResetCodeCutoff);
|
||||||
|
LocalDateTime inactiveUserCutoff = LocalDateTime.now().minusDays(7);
|
||||||
|
userRepository.deleteAllByActivatedFalseAndCreatedAtBefore(inactiveUserCutoff);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.auth.service;
|
||||||
|
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserAccountDataRequestRepository;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.UserAccountDataRequest;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class DataRequestService {
|
||||||
|
private final UserAccountDataRequestRepository dataRequestRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public DataRequestService(UserAccountDataRequestRepository dataRequestRepository, UserRepository userRepository) {
|
||||||
|
this.dataRequestRepository = dataRequestRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void createRequest(String userId) {
|
||||||
|
if (dataRequestRepository.existsByUserIdAndFulfilledFalse(userId)) {
|
||||||
|
return; // If there's already an open request that hasn't been fulfilled, ignore this one.
|
||||||
|
}
|
||||||
|
User user = userRepository.findById(userId).orElseThrow();
|
||||||
|
dataRequestRepository.save(new UserAccountDataRequest(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add scheduled task and logic for preparing user data exports.
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
|
@ -29,6 +30,10 @@ import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This service is responsible for generating, verifying, and generally managing
|
||||||
|
* authentication tokens.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class TokenService {
|
public class TokenService {
|
||||||
private static final Logger log = LoggerFactory.getLogger(TokenService.class);
|
private static final Logger log = LoggerFactory.getLogger(TokenService.class);
|
||||||
|
@ -47,7 +52,12 @@ public class TokenService {
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String generateAccessToken(User user) {
|
/**
|
||||||
|
* Generates a new short-lived access token for the given user.
|
||||||
|
* @param user The user to generate an access token for.
|
||||||
|
* @return The access token string.
|
||||||
|
*/
|
||||||
|
private String generateAccessToken(User user) {
|
||||||
Instant expiration = Instant.now().plus(30, ChronoUnit.MINUTES);
|
Instant expiration = Instant.now().plus(30, ChronoUnit.MINUTES);
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.setSubject(user.getId())
|
.setSubject(user.getId())
|
||||||
|
@ -63,6 +73,12 @@ public class TokenService {
|
||||||
.compact();
|
.compact();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new access token for a given set of credentials.
|
||||||
|
* @param credentials The credentials to use for authentication.
|
||||||
|
* @return A token response.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
public TokenResponse generateAccessToken(TokenCredentials credentials) {
|
public TokenResponse generateAccessToken(TokenCredentials credentials) {
|
||||||
User user = userRepository.findByEmailWithRoles(credentials.email())
|
User user = userRepository.findByEmailWithRoles(credentials.email())
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.domains.auth.service;
|
||||||
|
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.api.dao.submission.SubmissionRepository;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.dao.*;
|
||||||
|
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class UserAccountDeletionService {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(UserAccountDeletionService.class);
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final UserReportRepository userReportRepository;
|
||||||
|
private final UserFollowingRepository userFollowingRepository;
|
||||||
|
private final UserActivationCodeRepository userActivationCodeRepository;
|
||||||
|
private final EmailResetCodeRepository emailResetCodeRepository;
|
||||||
|
private final PasswordResetCodeRepository passwordResetCodeRepository;
|
||||||
|
private final SubmissionRepository submissionRepository;
|
||||||
|
|
||||||
|
public UserAccountDeletionService(UserRepository userRepository,
|
||||||
|
UserReportRepository userReportRepository,
|
||||||
|
UserFollowingRepository userFollowingRepository,
|
||||||
|
UserActivationCodeRepository userActivationCodeRepository,
|
||||||
|
EmailResetCodeRepository emailResetCodeRepository,
|
||||||
|
PasswordResetCodeRepository passwordResetCodeRepository,
|
||||||
|
SubmissionRepository submissionRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.userReportRepository = userReportRepository;
|
||||||
|
this.userFollowingRepository = userFollowingRepository;
|
||||||
|
this.userActivationCodeRepository = userActivationCodeRepository;
|
||||||
|
this.emailResetCodeRepository = emailResetCodeRepository;
|
||||||
|
this.passwordResetCodeRepository = passwordResetCodeRepository;
|
||||||
|
this.submissionRepository = submissionRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteAccount(User user) {
|
||||||
|
logger.info("Deleting user account {}", user.getEmail());
|
||||||
|
|
||||||
|
passwordResetCodeRepository.deleteAllByUser(user);
|
||||||
|
emailResetCodeRepository.deleteAllByUser(user);
|
||||||
|
userActivationCodeRepository.deleteAllByUser(user);
|
||||||
|
userReportRepository.deleteAllByUserOrReportedBy(user, user);
|
||||||
|
userFollowingRepository.deleteAllByFollowedUserOrFollowingUser(user, user);
|
||||||
|
submissionRepository.deleteAllByUser(user);
|
||||||
|
userRepository.deleteById(user.getId());
|
||||||
|
}
|
||||||
|
}
|
|
@ -236,6 +236,8 @@ public class UserService {
|
||||||
if (userRepository.existsByEmail(payload.newEmail())) {
|
if (userRepository.existsByEmail(payload.newEmail())) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email is taken.");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email is taken.");
|
||||||
}
|
}
|
||||||
|
// Delete any existing email reset code for the chosen email.
|
||||||
|
emailResetCodeRepository.deleteByNewEmail(payload.newEmail());
|
||||||
EmailResetCode emailResetCode = emailResetCodeRepository.save(new EmailResetCode(
|
EmailResetCode emailResetCode = emailResetCodeRepository.save(new EmailResetCode(
|
||||||
StringGenerator.randomString(127, StringGenerator.Alphabet.ALPHANUMERIC),
|
StringGenerator.randomString(127, StringGenerator.Alphabet.ALPHANUMERIC),
|
||||||
payload.newEmail(),
|
payload.newEmail(),
|
||||||
|
|
|
@ -263,6 +263,14 @@ class AuthModule {
|
||||||
const response = await api.get('/auth/me/roles', authStore.axiosConfig);
|
const response = await api.get('/auth/me/roles', authStore.axiosConfig);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async requestAccountData(authStore: AuthStoreType): Promise<void> {
|
||||||
|
await api.post('/auth/me/data-requests', null, authStore.axiosConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteAccount(authStore: AuthStoreType): Promise<void> {
|
||||||
|
await api.delete('/auth/me', authStore.axiosConfig);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AuthModule;
|
export default AuthModule;
|
||||||
|
|
|
@ -21,13 +21,13 @@ declare module 'vue-i18n' {
|
||||||
}
|
}
|
||||||
/* eslint-enable @typescript-eslint/no-empty-interface */
|
/* eslint-enable @typescript-eslint/no-empty-interface */
|
||||||
|
|
||||||
export default boot(({ app }) => {
|
export const i18n = createI18n({
|
||||||
const i18n = createI18n({
|
|
||||||
locale: 'en-US',
|
locale: 'en-US',
|
||||||
legacy: false,
|
legacy: false,
|
||||||
messages,
|
messages,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export default boot(({ app }) => {
|
||||||
// Set the locale to the preferred locale, if possible.
|
// Set the locale to the preferred locale, if possible.
|
||||||
const userLocale = window.navigator.language;
|
const userLocale = window.navigator.language;
|
||||||
if (userLocale === 'nl-NL') {
|
if (userLocale === 'nl-NL') {
|
||||||
|
|
|
@ -17,7 +17,7 @@ account-related actions.
|
||||||
<q-item-label>{{ $t('accountMenuItem.profile') }}</q-item-label>
|
<q-item-label>{{ $t('accountMenuItem.profile') }}</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-item clickable v-close-popup :to="getUserRoute(authStore.user) + '/settings'">
|
<q-item clickable v-close-popup to="/me/settings">
|
||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label>{{ $t('accountMenuItem.settings') }}</q-item-label>
|
<q-item-label>{{ $t('accountMenuItem.settings') }}</q-item-label>
|
||||||
</q-item-section>
|
</q-item-section>
|
||||||
|
|
|
@ -18,6 +18,11 @@ export default {
|
||||||
register: 'Register',
|
register: 'Register',
|
||||||
error: 'An error occurred.',
|
error: 'An error occurred.',
|
||||||
},
|
},
|
||||||
|
registrationSuccessPage: {
|
||||||
|
title: 'Account Registration Complete!',
|
||||||
|
p1: 'Check your email for the link to activate your account.',
|
||||||
|
p2: 'You may safely close this page.'
|
||||||
|
},
|
||||||
loginPage: {
|
loginPage: {
|
||||||
title: 'Login to Gymboard',
|
title: 'Login to Gymboard',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
|
@ -67,6 +72,7 @@ export default {
|
||||||
userSettingsPage: {
|
userSettingsPage: {
|
||||||
title: 'Account Settings',
|
title: 'Account Settings',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
|
changeEmail: 'Change your email address',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
passwordHint: 'Set a new password for your account.',
|
passwordHint: 'Set a new password for your account.',
|
||||||
|
@ -89,7 +95,33 @@ export default {
|
||||||
language: 'Language'
|
language: 'Language'
|
||||||
},
|
},
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
undo: 'Undo'
|
undo: 'Undo',
|
||||||
|
actions: {
|
||||||
|
title: 'Actions',
|
||||||
|
requestData: 'Request Account Data',
|
||||||
|
deleteAccount: 'Delete Account'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateEmailPage: {
|
||||||
|
title: 'Update Email Address',
|
||||||
|
inputHint: 'Enter your new email address here',
|
||||||
|
beforeUpdateInfo: "To update your email address, we'll send a secret code to your new address.",
|
||||||
|
updateButton: 'Update Email Address',
|
||||||
|
resetCodeSent: 'A reset code has been sent to your new email address.',
|
||||||
|
resetCodeInputHint: 'Enter your code here',
|
||||||
|
emailUpdated: 'Your email has been updated successfully.'
|
||||||
|
},
|
||||||
|
requestAccountDataPage: {
|
||||||
|
title: 'Request Account Data',
|
||||||
|
requestButton: 'Request Account Data',
|
||||||
|
requestSent: 'Request sent. You will receive an email with a link to download your data in a few days.'
|
||||||
|
},
|
||||||
|
deleteAccountPage: {
|
||||||
|
title: 'Delete Account',
|
||||||
|
deleteButton: 'Delete Account',
|
||||||
|
confirmTitle: 'Confirm Deletion',
|
||||||
|
confirmMessage: 'Are you absolutely certain that you want to delete your Gymboard account? This CANNOT be undone.',
|
||||||
|
accountDeleted: 'Account deleted. You will now be logged out. Goodbye 😭'
|
||||||
},
|
},
|
||||||
submissionPage: {
|
submissionPage: {
|
||||||
confirmDeletion: 'Confirm Deletion',
|
confirmDeletion: 'Confirm Deletion',
|
||||||
|
@ -108,5 +140,9 @@ export default {
|
||||||
weightUnit: {
|
weightUnit: {
|
||||||
kilograms: 'Kilograms',
|
kilograms: 'Kilograms',
|
||||||
pounds: 'Pounds'
|
pounds: 'Pounds'
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
title: 'Confirm',
|
||||||
|
message: 'Are you sure you want to continue?'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -62,6 +62,7 @@ export default {
|
||||||
userSettingsPage: {
|
userSettingsPage: {
|
||||||
title: 'Account instellingen',
|
title: 'Account instellingen',
|
||||||
email: 'E-mail',
|
email: 'E-mail',
|
||||||
|
changeEmail: 'E-mail adres wijzigen',
|
||||||
name: 'Naam',
|
name: 'Naam',
|
||||||
password: 'Wachtwoord',
|
password: 'Wachtwoord',
|
||||||
passwordHint: 'Stel een nieuw wachtwoord voor je account in.',
|
passwordHint: 'Stel een nieuw wachtwoord voor je account in.',
|
||||||
|
|
|
@ -57,7 +57,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {onMounted, ref} from 'vue';
|
import {ref} from 'vue';
|
||||||
import AccountMenuItem from 'components/AccountMenuItem.vue';
|
import AccountMenuItem from 'components/AccountMenuItem.vue';
|
||||||
import {useAuthStore} from 'stores/auth-store';
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
|
|
||||||
|
@ -67,8 +67,4 @@ const leftDrawerOpen = ref(false);
|
||||||
function toggleLeftDrawer() {
|
function toggleLeftDrawer() {
|
||||||
leftDrawerOpen.value = !leftDrawerOpen.value;
|
leftDrawerOpen.value = !leftDrawerOpen.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await authStore.tryLogInWithStoredToken();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -41,7 +41,7 @@ import { DateTime } from 'luxon';
|
||||||
import { getFileUrl } from 'src/api/cdn';
|
import { getFileUrl } from 'src/api/cdn';
|
||||||
import { getGymRoute } from 'src/router/gym-routing';
|
import { getGymRoute } from 'src/router/gym-routing';
|
||||||
import {useAuthStore} from 'stores/auth-store';
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
import {showApiErrorToast} from 'src/utils';
|
import {confirm, showApiErrorToast} from 'src/utils';
|
||||||
import {useI18n} from 'vue-i18n';
|
import {useI18n} from 'vue-i18n';
|
||||||
import {useQuasar} from 'quasar';
|
import {useQuasar} from 'quasar';
|
||||||
|
|
||||||
|
@ -69,11 +69,10 @@ onMounted(async () => {
|
||||||
* the user back to their home page that shows all their lifts.
|
* the user back to their home page that shows all their lifts.
|
||||||
*/
|
*/
|
||||||
async function deleteSubmission() {
|
async function deleteSubmission() {
|
||||||
quasar.dialog({
|
confirm({
|
||||||
title: i18n.t('submissionPage.confirmDeletion'),
|
title: i18n.t('submissionPage.confirmDeletion'),
|
||||||
message: i18n.t('submissionPage.confirmDeletionMsg'),
|
message: i18n.t('submissionPage.confirmDeletionMsg')
|
||||||
cancel: true
|
}).then(async () => {
|
||||||
}).onOk(async () => {
|
|
||||||
if (!submission.value) return;
|
if (!submission.value) return;
|
||||||
try {
|
try {
|
||||||
await api.gyms.submissions.deleteSubmission(submission.value.id, authStore);
|
await api.gyms.submissions.deleteSubmission(submission.value.id, authStore);
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
<template>
|
||||||
|
<q-page>
|
||||||
|
<StandardCenteredPage>
|
||||||
|
<h3>{{ $t('deleteAccountPage.title') }}</h3>
|
||||||
|
<hr>
|
||||||
|
<div v-if="contentVersion === 'en-US'">
|
||||||
|
<p>
|
||||||
|
On this page, you may choose to delete your Gymboard account. This
|
||||||
|
action removes all Gymboard data associated with your account,
|
||||||
|
permanently, without any possibility of recovery. Please consider
|
||||||
|
carefully before proceeding.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row justify-end">
|
||||||
|
<q-btn
|
||||||
|
:label="$t('deleteAccountPage.deleteButton')"
|
||||||
|
color="secondary"
|
||||||
|
@click="deleteAccount()"
|
||||||
|
:disable="sent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</StandardCenteredPage>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||||
|
import {computed, ref} from 'vue';
|
||||||
|
import {useI18n} from 'vue-i18n';
|
||||||
|
import {confirm, showApiErrorToast, showSuccessToast, sleep} from 'src/utils';
|
||||||
|
import api from 'src/api/main';
|
||||||
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
|
import {useRouter} from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const sent = ref(false);
|
||||||
|
|
||||||
|
const contentVersion = computed(() => {
|
||||||
|
if (i18n.locale.value === 'nl-NL') {
|
||||||
|
// TODO: Add dutch translation!
|
||||||
|
}
|
||||||
|
return 'en-US';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deleteAccount() {
|
||||||
|
confirm({
|
||||||
|
title: i18n.t('deleteAccountPage.confirmTitle'),
|
||||||
|
message: i18n.t('deleteAccountPage.confirmMessage')
|
||||||
|
}).then(async () => {
|
||||||
|
sent.value = true;
|
||||||
|
try {
|
||||||
|
await api.auth.deleteAccount(authStore);
|
||||||
|
showSuccessToast('deleteAccountPage.accountDeleted');
|
||||||
|
await sleep(1000);
|
||||||
|
authStore.logOut();
|
||||||
|
await router.push('/');
|
||||||
|
} catch (error: any) {
|
||||||
|
showApiErrorToast(error);
|
||||||
|
await sleep(1000);
|
||||||
|
sent.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -1,8 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<StandardCenteredPage>
|
<StandardCenteredPage>
|
||||||
<h3 class="text-center">{{ $t('registrationSuccessPage.title') }}</h3>
|
<h3 class="text-center">{{ $t('registrationSuccessPage.title') }}</h3>
|
||||||
<p>Check your email for the link to activate your account.</p>
|
<p>{{ $t('registrationSuccessPage.p1') }}</p>
|
||||||
<p>You may safely close this page.</p>
|
<p>{{ $t('registrationSuccessPage.p2') }}</p>
|
||||||
</StandardCenteredPage>
|
</StandardCenteredPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
<template>
|
||||||
|
<q-page>
|
||||||
|
<StandardCenteredPage>
|
||||||
|
<h3>{{ $t('requestAccountDataPage.title') }}</h3>
|
||||||
|
<hr>
|
||||||
|
<div v-if="contentVersion === 'en-US'">
|
||||||
|
<p>
|
||||||
|
You have the right to issue a request for the data that Gymboard keeps
|
||||||
|
for your user account. This may include, but is not limited to, basic
|
||||||
|
user information (username, name, preferences, settings), historical
|
||||||
|
data from previous user information you've provided, and lifting
|
||||||
|
submission videos and their associated metadata.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Gymboard makes a best effort to provide account data in a reasonable
|
||||||
|
timeframe, while also accounting for the increased load this places on
|
||||||
|
our services. Therefore, it may take up to <strong>7 days</strong> for
|
||||||
|
your account data to be ready for download after issuing a request.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Account data is formatted as compressed ZIP archive containing JSON
|
||||||
|
files, as well as media files for any media you've uploaded. You will
|
||||||
|
receive an email with a direct link to download the account data, once
|
||||||
|
the request has been fulfilled. This link will expire after a few
|
||||||
|
days, after which you must issue a new request to download your data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row justify-end">
|
||||||
|
<q-btn
|
||||||
|
:label="$t('requestAccountDataPage.requestButton')"
|
||||||
|
color="secondary"
|
||||||
|
@click="sendRequest()"
|
||||||
|
:disable="sent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</StandardCenteredPage>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||||
|
import {useI18n} from 'vue-i18n';
|
||||||
|
import {computed, ref} from 'vue';
|
||||||
|
import api from 'src/api/main';
|
||||||
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
|
import {showApiErrorToast, showSuccessToast} from 'src/utils';
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const sent = ref(false);
|
||||||
|
|
||||||
|
const contentVersion = computed(() => {
|
||||||
|
if (i18n.locale.value === 'nl-NL') {
|
||||||
|
// TODO: Add dutch translation!
|
||||||
|
}
|
||||||
|
return 'en-US';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function sendRequest() {
|
||||||
|
try {
|
||||||
|
await api.auth.requestAccountData(authStore);
|
||||||
|
showSuccessToast('requestAccountDataPage.requestSent');
|
||||||
|
sent.value = true;
|
||||||
|
} catch (error: any) {
|
||||||
|
showApiErrorToast(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -0,0 +1,101 @@
|
||||||
|
<template>
|
||||||
|
<q-page>
|
||||||
|
<StandardCenteredPage v-if="authStore.loggedIn">
|
||||||
|
<h3>{{ $t('updateEmailPage.title') }}</h3>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div v-if="!waitingForResetCode">
|
||||||
|
<div class="row q-mt-md">
|
||||||
|
<p>{{ $t('updateEmailPage.beforeUpdateInfo') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<q-input
|
||||||
|
type="email"
|
||||||
|
v-model="email"
|
||||||
|
:hint="$t('updateEmailPage.inputHint')"
|
||||||
|
class="full-width"
|
||||||
|
:readonly="waitingForResetCode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row justify-end">
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
:label="$t('updateEmailPage.updateButton')"
|
||||||
|
:disable="!updateButtonEnabled"
|
||||||
|
@click="requestEmailCode()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="waitingForResetCode">
|
||||||
|
<div class="row">
|
||||||
|
<q-input
|
||||||
|
type="text"
|
||||||
|
v-model="resetCode"
|
||||||
|
:hint="$t('updateEmailPage.resetCodeInputHint')"
|
||||||
|
class="full-width"
|
||||||
|
@change="resetCodeChanged()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</StandardCenteredPage>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import StandardCenteredPage from "components/StandardCenteredPage.vue";
|
||||||
|
import {useAuthStore} from "stores/auth-store";
|
||||||
|
import {computed, onBeforeMount, onMounted, ref} from "vue";
|
||||||
|
import {useRouter} from "vue-router";
|
||||||
|
import api from 'src/api/main';
|
||||||
|
import {showApiErrorToast, showSuccessToast, sleep} from "src/utils";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const email = ref('');
|
||||||
|
const resetCode = ref('');
|
||||||
|
const waitingForResetCode = ref(false);
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
if (!authStore.user) {
|
||||||
|
router.replace('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
email.value = authStore.user.email;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateButtonEnabled = computed(() => {
|
||||||
|
return email.value &&
|
||||||
|
email.value.trim().length > 3 &&
|
||||||
|
email.value.trim() !== authStore.user?.email;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function requestEmailCode() {
|
||||||
|
try {
|
||||||
|
await api.auth.generateEmailResetCode(email.value, authStore);
|
||||||
|
waitingForResetCode.value = true;
|
||||||
|
showSuccessToast('updateEmailPage.resetCodeSent');
|
||||||
|
} catch (error: any) {
|
||||||
|
showApiErrorToast(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetCodeChanged() {
|
||||||
|
if (resetCode.value && resetCode.value.trim().length > 0) {
|
||||||
|
const code = resetCode.value.trim();
|
||||||
|
try {
|
||||||
|
await api.auth.updateMyEmail(code, authStore);
|
||||||
|
showSuccessToast('updateEmailPage.emailUpdated');
|
||||||
|
await sleep(2000);
|
||||||
|
authStore.logOut();
|
||||||
|
await router.push('/login');
|
||||||
|
} catch (error: any) {
|
||||||
|
showApiErrorToast(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -3,7 +3,7 @@ The page where users can edit their personal information and preferences.
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<q-page>
|
<q-page>
|
||||||
<StandardCenteredPage v-if="authStore.loggedIn">
|
<StandardCenteredPage>
|
||||||
<h3>{{ $t('userSettingsPage.title') }}</h3>
|
<h3>{{ $t('userSettingsPage.title') }}</h3>
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
|
@ -11,6 +11,11 @@ The page where users can edit their personal information and preferences.
|
||||||
<span class="property-label">{{ $t('userSettingsPage.email') }}</span>
|
<span class="property-label">{{ $t('userSettingsPage.email') }}</span>
|
||||||
<q-input type="email" v-model="authStore.user.email" dense readonly/>
|
<q-input type="email" v-model="authStore.user.email" dense readonly/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row justify-end">
|
||||||
|
<router-link to="/me/update-email" class="text-secondary">
|
||||||
|
{{ $t('userSettingsPage.changeEmail') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
<div class="row justify-between">
|
<div class="row justify-between">
|
||||||
<span class="property-label">{{ $t('userSettingsPage.name') }}</span>
|
<span class="property-label">{{ $t('userSettingsPage.name') }}</span>
|
||||||
<q-input type="text" v-model="authStore.user.name" dense readonly/>
|
<q-input type="text" v-model="authStore.user.name" dense readonly/>
|
||||||
|
@ -86,13 +91,22 @@ The page where users can edit their personal information and preferences.
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4>{{ $t('userSettingsPage.actions.title') }}</h4>
|
||||||
|
<div class="row q-my-md">
|
||||||
|
<q-btn :label="$t('userSettingsPage.actions.requestData')" color="secondary" to="/me/request-account-data"/>
|
||||||
|
</div>
|
||||||
|
<div class="row q-my-md">
|
||||||
|
<q-btn :label="$t('userSettingsPage.actions.deleteAccount')" color="secondary" to="/me/delete-account"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</StandardCenteredPage>
|
</StandardCenteredPage>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
|
||||||
import {useRoute, useRouter} from 'vue-router';
|
|
||||||
import {useAuthStore} from 'stores/auth-store';
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
import {computed, onMounted, ref, Ref, toRaw} from 'vue';
|
import {computed, onMounted, ref, Ref, toRaw} from 'vue';
|
||||||
import {UserPersonalDetails, UserPreferences} from 'src/api/main/auth';
|
import {UserPersonalDetails, UserPreferences} from 'src/api/main/auth';
|
||||||
|
@ -103,8 +117,6 @@ import {resolveLocale, supportedLocales} from 'src/i18n';
|
||||||
import {useI18n} from 'vue-i18n';
|
import {useI18n} from 'vue-i18n';
|
||||||
import {showApiErrorToast, showSuccessToast, showWarningToast} from 'src/utils';
|
import {showApiErrorToast, showSuccessToast, showWarningToast} from 'src/utils';
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const i18n = useI18n({useScope: 'global'});
|
const i18n = useI18n({useScope: 'global'});
|
||||||
|
|
||||||
|
@ -117,8 +129,6 @@ let initialPreferences: UserPreferences | null = null;
|
||||||
const newPassword = ref('');
|
const newPassword = ref('');
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const userId = route.params.userId as string;
|
|
||||||
if (authStore.user && authStore.user.id === userId) {
|
|
||||||
personalDetails.value = await api.auth.getMyPersonalDetails(authStore);
|
personalDetails.value = await api.auth.getMyPersonalDetails(authStore);
|
||||||
initialPersonalDetails = structuredClone(toRaw(personalDetails.value));
|
initialPersonalDetails = structuredClone(toRaw(personalDetails.value));
|
||||||
|
|
||||||
|
@ -126,10 +136,6 @@ onMounted(async () => {
|
||||||
initialPreferences = structuredClone(toRaw(preferences.value));
|
initialPreferences = structuredClone(toRaw(preferences.value));
|
||||||
|
|
||||||
newPassword.value = '';
|
newPassword.value = '';
|
||||||
} else {
|
|
||||||
// Redirect away from the page if the user isn't viewing their own settings.
|
|
||||||
await router.replace(`/users/${userId}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const personalDetailsChanged = computed(() => {
|
const personalDetailsChanged = computed(() => {
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from 'vue-router';
|
} from 'vue-router';
|
||||||
|
|
||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* If not building with SSR mode, you can
|
* If not building with SSR mode, you can
|
||||||
|
@ -24,7 +25,7 @@ export default route(function (/* { store, ssrContext } */) {
|
||||||
? createWebHistory
|
? createWebHistory
|
||||||
: createWebHashHistory;
|
: createWebHashHistory;
|
||||||
|
|
||||||
const Router = createRouter({
|
const router = createRouter({
|
||||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||||
routes,
|
routes,
|
||||||
|
|
||||||
|
@ -34,5 +35,16 @@ export default route(function (/* { store, ssrContext } */) {
|
||||||
history: createHistory(process.env.VUE_ROUTER_BASE),
|
history: createHistory(process.env.VUE_ROUTER_BASE),
|
||||||
});
|
});
|
||||||
|
|
||||||
return Router;
|
// Before navigating to any route, we add a guard that tries to log in if the
|
||||||
|
// user has a stored authentication token. This way, if a user reloads a page
|
||||||
|
// that can only be accessed through authentication, they'll go back to it
|
||||||
|
// instead of being kicked out to the main page.
|
||||||
|
router.beforeEach(async () => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
if (!authStore.loggedIn) {
|
||||||
|
await authStore.tryLogInWithStoredToken();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { RouteRecordRaw } from 'vue-router';
|
import {RouteRecordRaw} from 'vue-router';
|
||||||
import MainLayout from 'layouts/MainLayout.vue';
|
import MainLayout from 'layouts/MainLayout.vue';
|
||||||
import GymSearchPage from 'pages/GymSearchPage.vue';
|
import GymSearchPage from 'pages/GymSearchPage.vue';
|
||||||
import GymPage from 'pages/gym/GymPage.vue';
|
import GymPage from 'pages/gym/GymPage.vue';
|
||||||
|
@ -16,6 +16,9 @@ import UserSearchPage from 'pages/UserSearchPage.vue';
|
||||||
import AdminPage from 'pages/admin/AdminPage.vue';
|
import AdminPage from 'pages/admin/AdminPage.vue';
|
||||||
import {useAuthStore} from 'stores/auth-store';
|
import {useAuthStore} from 'stores/auth-store';
|
||||||
import AboutPage from 'pages/AboutPage.vue';
|
import AboutPage from 'pages/AboutPage.vue';
|
||||||
|
import UpdateEmailPage from "pages/auth/UpdateEmailPage.vue";
|
||||||
|
import RequestAccountDataPage from "pages/auth/RequestAccountDataPage.vue";
|
||||||
|
import DeleteAccountPage from "pages/auth/DeleteAccountPage.vue";
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
// Auth-related pages, which live outside the main layout.
|
// Auth-related pages, which live outside the main layout.
|
||||||
|
@ -31,7 +34,6 @@ const routes: RouteRecordRaw[] = [
|
||||||
children: [
|
children: [
|
||||||
{ path: '', component: GymSearchPage },
|
{ path: '', component: GymSearchPage },
|
||||||
{ path: 'users', component: UserSearchPage },
|
{ path: 'users', component: UserSearchPage },
|
||||||
{ path: 'users/:userId/settings', component: UserSettingsPage },
|
|
||||||
{ // Match anything under /users/:userId to the UserPage, since it manages sub-pages manually.
|
{ // Match anything under /users/:userId to the UserPage, since it manages sub-pages manually.
|
||||||
path: 'users/:userId+',
|
path: 'users/:userId+',
|
||||||
component: UserPage
|
component: UserPage
|
||||||
|
@ -53,6 +55,21 @@ const routes: RouteRecordRaw[] = [
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ path: 'about', component: AboutPage },
|
{ path: 'about', component: AboutPage },
|
||||||
|
|
||||||
|
// Pages under /me are accessible only when authenticated.
|
||||||
|
{
|
||||||
|
path: 'me',
|
||||||
|
beforeEnter: () => {
|
||||||
|
const s = useAuthStore();
|
||||||
|
if (!s.loggedIn) return '/';
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{ path: 'settings', component: UserSettingsPage },
|
||||||
|
{ path: 'update-email', component: UpdateEmailPage },
|
||||||
|
{ path: 'request-account-data', component: RequestAccountDataPage },
|
||||||
|
{ path: 'delete-account', component: DeleteAccountPage }
|
||||||
|
]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import {useQuasar} from 'quasar';
|
import {i18n} from 'boot/i18n';
|
||||||
import {useI18n} from 'vue-i18n';
|
import {Notify, Dialog, QDialogOptions} from 'quasar';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sleeps for a given number of milliseconds before resolving.
|
* Sleeps for a given number of milliseconds before resolving.
|
||||||
|
@ -7,11 +7,36 @@ import {useI18n} from 'vue-i18n';
|
||||||
*/
|
*/
|
||||||
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a confirmation dialog that returns a promise which resolves if the
|
||||||
|
* user clicks on the affirmative button choice.
|
||||||
|
* @param options Options to supply to the dialog, instead of defaults.
|
||||||
|
*/
|
||||||
|
export function confirm(options?: QDialogOptions): Promise<void> {
|
||||||
|
const { t } = i18n.global;
|
||||||
|
const dialogOpts: QDialogOptions = {
|
||||||
|
title: t('confirm.title'),
|
||||||
|
message: t('confirm.message'),
|
||||||
|
cancel: true
|
||||||
|
};
|
||||||
|
if (options?.title) {
|
||||||
|
dialogOpts.title = options.title;
|
||||||
|
}
|
||||||
|
if (options?.message) {
|
||||||
|
dialogOpts.message = options.message;
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
Dialog.create(dialogOpts)
|
||||||
|
.onOk(resolve)
|
||||||
|
.onCancel(reject)
|
||||||
|
.onDismiss(reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function showToast(type: string, messageKey: string) {
|
function showToast(type: string, messageKey: string) {
|
||||||
const quasar = useQuasar();
|
const { t } = i18n.global;
|
||||||
const i18n = useI18n();
|
Notify.create({
|
||||||
quasar.notify({
|
message: t(messageKey),
|
||||||
message: i18n.t(messageKey),
|
|
||||||
type: type,
|
type: type,
|
||||||
position: 'top'
|
position: 'top'
|
||||||
});
|
});
|
||||||
|
@ -25,10 +50,10 @@ function showToast(type: string, messageKey: string) {
|
||||||
* @param error The error to display.
|
* @param error The error to display.
|
||||||
*/
|
*/
|
||||||
export function showApiErrorToast(error?: unknown) {
|
export function showApiErrorToast(error?: unknown) {
|
||||||
showToast('danger', 'generalErrors.apiError');
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
showToast('danger', 'generalErrors.apiError');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showInfoToast(messageKey: string) {
|
export function showInfoToast(messageKey: string) {
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
],
|
],
|
||||||
"copyright": "Copyright © 2023, Andrew Lalis",
|
"copyright": "Copyright © 2023, Andrew Lalis",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"handy-httpd": "~>5.7.0",
|
"handy-httpd": "~>6.0.0",
|
||||||
"slf4d": "~>2.1.1"
|
"slf4d": "~>2.1.1"
|
||||||
},
|
},
|
||||||
"description": "Service for handling Gymboard file uploads.",
|
"description": "Service for handling Gymboard file uploads.",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"fileVersion": 1,
|
"fileVersion": 1,
|
||||||
"versions": {
|
"versions": {
|
||||||
"handy-httpd": "5.7.0",
|
"handy-httpd": "6.0.0",
|
||||||
"httparsed": "1.2.1",
|
"httparsed": "1.2.1",
|
||||||
"slf4d": "2.1.1"
|
"slf4d": "2.1.1"
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ void main() {
|
||||||
ctx.response.writeBodyString("online");
|
ctx.response.writeBodyString("online");
|
||||||
});
|
});
|
||||||
pathHandler.addMapping("POST", "/uploads", new VideoUploadHandler());
|
pathHandler.addMapping("POST", "/uploads", new VideoUploadHandler());
|
||||||
|
pathHandler.addMapping("POST", "/uploads/{uploadId}/process", new VideoProcessingHandler());
|
||||||
|
|
||||||
HttpServer server = new HttpServer(pathHandler, getServerConfig());
|
HttpServer server = new HttpServer(pathHandler, getServerConfig());
|
||||||
server.start();
|
server.start();
|
||||||
|
|
|
@ -1,25 +1,75 @@
|
||||||
module handlers;
|
module handlers;
|
||||||
|
|
||||||
import handy_httpd;
|
import handy_httpd;
|
||||||
|
import slf4d;
|
||||||
import std.conv : to;
|
import std.conv : to;
|
||||||
|
import std.path;
|
||||||
|
import std.file;
|
||||||
|
import std.uuid;
|
||||||
|
import std.json;
|
||||||
|
import std.stdio;
|
||||||
|
|
||||||
const ulong MAX_UPLOAD_SIZE = 1024 * 1024 * 1024;
|
static immutable MAX_UPLOAD_SIZE = 1024 * 1024 * 1024;
|
||||||
|
static immutable ALLOWED_MEDIA_TYPES = ["video/mp4"];
|
||||||
|
static immutable TEMP_UPLOADS_DIR = "temp-uploads";
|
||||||
|
|
||||||
class VideoUploadHandler : HttpRequestHandler {
|
class VideoUploadHandler : HttpRequestHandler {
|
||||||
public void handle(ref HttpRequestContext ctx) {
|
public void handle(ref HttpRequestContext ctx) {
|
||||||
if ("Content-Length" !in ctx.request.headers) {
|
if (!validateHeaders(ctx)) return;
|
||||||
ctx.response.status = 411;
|
|
||||||
ctx.response.statusText = "Length Required";
|
if (!exists(TEMP_UPLOADS_DIR)) mkdir(TEMP_UPLOADS_DIR);
|
||||||
return;
|
|
||||||
|
UUID uploadId = sha1UUID("gymboard-uploads");
|
||||||
|
|
||||||
|
ctx.request.readBodyToFile(getTempFilePath(uploadId));
|
||||||
|
JSONValue metadataObj = JSONValue(string[string].init); // Empty object.
|
||||||
|
string originalFilename = ctx.request.getHeader("X-GYMBOARD-FILENAME");
|
||||||
|
if (originalFilename is null) {
|
||||||
|
originalFilename = "unnamed.mp4";
|
||||||
|
}
|
||||||
|
metadataObj.object["filename"] = originalFilename;
|
||||||
|
File f = File(getTempFileMetadataPath(uploadId), "w");
|
||||||
|
f.write(metadataObj.toPrettyString());
|
||||||
|
f.close();
|
||||||
|
|
||||||
|
infoF!"Saved uploaded video file with id %s."(uploadId.toString);
|
||||||
|
|
||||||
|
ctx.response.setStatus(HttpStatus.CREATED);
|
||||||
|
ctx.response.writeBodyString(uploadId.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
ulong contentLength = ctx.request.headers["Content-Length"].to!ulong;
|
private bool validateHeaders(ref HttpRequestContext ctx) {
|
||||||
if (contentLength == 0 || contentLength > MAX_UPLOAD_SIZE) {
|
ulong contentLength = ctx.request.getHeaderAs!ulong("Content-Length");
|
||||||
ctx.response.status = 413;
|
if (contentLength == 0) {
|
||||||
ctx.response.statusText = "Payload Too Large";
|
ctx.response.status = HttpStatus.LENGTH_REQUIRED;
|
||||||
return;
|
return false;
|
||||||
|
} else if (contentLength > MAX_UPLOAD_SIZE) {
|
||||||
|
ctx.response.status = HttpStatus.PAYLOAD_TOO_LARGE;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement this!
|
import std.algorithm : canFind;
|
||||||
|
string contentType = ctx.request.getHeader("Content-Type");
|
||||||
|
if (contentType is null || !canFind(ALLOWED_MEDIA_TYPES, contentType)) {
|
||||||
|
ctx.response.status = HttpStatus.UNSUPPORTED_MEDIA_TYPE;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string getTempFilePath(const ref UUID uploadId) {
|
||||||
|
return buildPath(TEMP_UPLOADS_DIR, uploadId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private string getTempFileMetadataPath(const ref UUID uploadId) {
|
||||||
|
return buildPath(TEMP_UPLOADS_DIR, uploadId.toString() ~ "_meta.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VideoProcessingHandler : HttpRequestHandler {
|
||||||
|
public void handle(ref HttpRequestContext ctx) {
|
||||||
|
string uploadIdStr = ctx.request.getPathParamAs!string("uploadId");
|
||||||
|
infoF!"Processing upload %s"(uploadIdStr);
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue