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