From 9d1712889e4b986a877052c78a0cb5884b5cba80 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Fri, 31 Mar 2023 19:02:46 +0200 Subject: [PATCH] Add account deletion, data requests, and more. --- .../dao/submission/SubmissionRepository.java | 4 + .../auth/controller/UserController.java | 21 +++- .../auth/dao/EmailResetCodeRepository.java | 7 ++ .../auth/dao/PasswordResetCodeRepository.java | 3 + .../dao/UserAccountDataRequestRepository.java | 10 ++ .../dao/UserActivationCodeRepository.java | 3 + .../auth/dao/UserFollowingRepository.java | 2 + .../auth/dao/UserReportRepository.java | 4 + .../domains/auth/dao/UserRepository.java | 6 ++ .../domains/auth/model/EmailResetCode.java | 2 +- .../auth/model/UserAccountDataRequest.java | 50 +++++++++ .../domains/auth/service/CleanupService.java | 17 ++- .../auth/service/DataRequestService.java | 30 ++++++ .../domains/auth/service/TokenService.java | 18 +++- .../service/UserAccountDeletionService.java | 51 +++++++++ .../domains/auth/service/UserService.java | 2 + gymboard-app/src/api/main/auth.ts | 8 ++ gymboard-app/src/boot/i18n.ts | 12 +-- .../src/components/AccountMenuItem.vue | 2 +- gymboard-app/src/i18n/en-US/index.ts | 38 ++++++- gymboard-app/src/i18n/nl-NL/index.ts | 1 + gymboard-app/src/layouts/MainLayout.vue | 6 +- gymboard-app/src/pages/SubmissionPage.vue | 9 +- .../src/pages/auth/DeleteAccountPage.vue | 72 +++++++++++++ .../pages/auth/RegistrationSuccessPage.vue | 4 +- .../src/pages/auth/RequestAccountDataPage.vue | 74 +++++++++++++ .../src/pages/auth/UpdateEmailPage.vue | 101 ++++++++++++++++++ .../src/pages/auth/UserSettingsPage.vue | 36 ++++--- gymboard-app/src/router/index.ts | 16 ++- gymboard-app/src/router/routes.ts | 21 +++- gymboard-app/src/utils.ts | 39 +++++-- gymboard-uploads/dub.json | 2 +- gymboard-uploads/dub.selections.json | 2 +- gymboard-uploads/source/app.d | 1 + gymboard-uploads/source/handlers.d | 78 +++++++++++--- 35 files changed, 681 insertions(+), 71 deletions(-) create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserAccountDataRequestRepository.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserAccountDataRequest.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/DataRequestService.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserAccountDeletionService.java create mode 100644 gymboard-app/src/pages/auth/DeleteAccountPage.vue create mode 100644 gymboard-app/src/pages/auth/RequestAccountDataPage.vue create mode 100644 gymboard-app/src/pages/auth/UpdateEmailPage.vue diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dao/submission/SubmissionRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dao/submission/SubmissionRepository.java index 0ce206f..d21792b 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dao/submission/SubmissionRepository.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dao/submission/SubmissionRepository.java @@ -1,10 +1,14 @@ 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.auth.model.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.stereotype.Repository; @Repository public interface SubmissionRepository extends JpaRepository, JpaSpecificationExecutor { + @Modifying + void deleteAllByUser(User user); } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/controller/UserController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/controller/UserController.java index 70da333..aeccd23 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/controller/UserController.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/controller/UserController.java @@ -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.User; 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.UserAccountDeletionService; import nl.andrewlalis.gymboard_api.domains.auth.service.UserService; import org.springframework.data.domain.Page; 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.web.bind.annotation.*; -import java.util.ArrayList; import java.util.List; @RestController public class UserController { private final UserService userService; + private final DataRequestService dataRequestService; + private final UserAccountDeletionService accountDeletionService; private final UserAccessService userAccessService; - public UserController(UserService userService, UserAccessService userAccessService) { + public UserController(UserService userService, DataRequestService dataRequestService, UserAccountDeletionService accountDeletionService, UserAccessService userAccessService) { this.userService = userService; + this.dataRequestService = dataRequestService; + this.accountDeletionService = accountDeletionService; this.userAccessService = userAccessService; } @@ -165,4 +170,16 @@ public class UserController { public List getMyRoles(@AuthenticationPrincipal User myUser) { return myUser.getRoles().stream().map(Role::getShortName).toList(); } + + @PostMapping(path = "/auth/me/data-requests") + public ResponseEntity requestData(@AuthenticationPrincipal User myUser) { + dataRequestService.createRequest(myUser.getId()); + return ResponseEntity.ok().build(); + } + + @DeleteMapping(path = "/auth/me") + public ResponseEntity deleteAccount(@AuthenticationPrincipal User myUser) { + accountDeletionService.deleteAccount(myUser); + return ResponseEntity.ok().build(); + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/EmailResetCodeRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/EmailResetCodeRepository.java index fe43518..314dc53 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/EmailResetCodeRepository.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/EmailResetCodeRepository.java @@ -1,6 +1,7 @@ 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.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.stereotype.Repository; @@ -11,4 +12,10 @@ import java.time.LocalDateTime; public interface EmailResetCodeRepository extends JpaRepository { @Modifying void deleteAllByCreatedAtBefore(LocalDateTime cutoff); + + boolean existsByNewEmail(String newEmail); + @Modifying + void deleteByNewEmail(String newEmail); + @Modifying + void deleteAllByUser(User user); } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/PasswordResetCodeRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/PasswordResetCodeRepository.java index 6efd5d8..6ab0905 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/PasswordResetCodeRepository.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/PasswordResetCodeRepository.java @@ -1,6 +1,7 @@ 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.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.stereotype.Repository; @@ -11,4 +12,6 @@ import java.time.LocalDateTime; public interface PasswordResetCodeRepository extends JpaRepository { @Modifying void deleteAllByCreatedAtBefore(LocalDateTime cutoff); + @Modifying + void deleteAllByUser(User user); } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserAccountDataRequestRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserAccountDataRequestRepository.java new file mode 100644 index 0000000..7910390 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserAccountDataRequestRepository.java @@ -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 { + boolean existsByUserIdAndFulfilledFalse(String userId); +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserActivationCodeRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserActivationCodeRepository.java index 05fdb52..633356d 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserActivationCodeRepository.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserActivationCodeRepository.java @@ -1,5 +1,6 @@ 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 org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -14,4 +15,6 @@ public interface UserActivationCodeRepository extends JpaRepository findAllByFollowedUserOrderByCreatedAtDesc(User followedUser, Pageable pageable); Page findAllByFollowingUserOrderByCreatedAtDesc(User followingUser, Pageable pageable); diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserReportRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserReportRepository.java index 6a58abb..04ef746 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserReportRepository.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserReportRepository.java @@ -1,9 +1,13 @@ 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 org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.stereotype.Repository; @Repository public interface UserReportRepository extends JpaRepository { + @Modifying + void deleteAllByUserOrReportedBy(User user, User reportedBy); } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserRepository.java index 030ab2e..f257d29 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserRepository.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserRepository.java @@ -1,11 +1,14 @@ package nl.andrewlalis.gymboard_api.domains.auth.dao; 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.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.Optional; @Repository @@ -18,4 +21,7 @@ public interface UserRepository extends JpaRepository, JpaSpecific @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email") Optional findByEmailWithRoles(String email); + + @Modifying + void deleteAllByActivatedFalseAndCreatedAtBefore(LocalDateTime cutoff); } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/EmailResetCode.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/EmailResetCode.java index b8c4ceb..abbedd6 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/EmailResetCode.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/EmailResetCode.java @@ -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 * 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 public class EmailResetCode { public static final Duration VALID_FOR = Duration.ofMinutes(30); diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserAccountDataRequest.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserAccountDataRequest.java new file mode 100644 index 0000000..c31d1bf --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserAccountDataRequest.java @@ -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; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/CleanupService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/CleanupService.java index d58f7ef..d9b7324 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/CleanupService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/CleanupService.java @@ -1,9 +1,6 @@ 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.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.dao.*; 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.UserActivationCode; @@ -21,12 +18,20 @@ public class CleanupService { private final UserActivationCodeRepository activationCodeRepository; private final UserFollowRequestRepository followRequestRepository; 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.activationCodeRepository = activationCodeRepository; this.followRequestRepository = followRequestRepository; this.emailResetCodeRepository = emailResetCodeRepository; + this.userRepository = userRepository; } /** @@ -44,5 +49,7 @@ public class CleanupService { followRequestRepository.deleteAllByCreatedAtBefore(followRequestCutoff); LocalDateTime emailResetCodeCutoff = LocalDateTime.now().minus(EmailResetCode.VALID_FOR); emailResetCodeRepository.deleteAllByCreatedAtBefore(emailResetCodeCutoff); + LocalDateTime inactiveUserCutoff = LocalDateTime.now().minusDays(7); + userRepository.deleteAllByActivatedFalseAndCreatedAtBefore(inactiveUserCutoff); } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/DataRequestService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/DataRequestService.java new file mode 100644 index 0000000..55cd678 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/DataRequestService.java @@ -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. +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/TokenService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/TokenService.java index 28cc61b..9bc583a 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/TokenService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/TokenService.java @@ -17,6 +17,7 @@ import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.nio.file.Files; @@ -29,6 +30,10 @@ import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.stream.Collectors; +/** + * This service is responsible for generating, verifying, and generally managing + * authentication tokens. + */ @Service public class TokenService { private static final Logger log = LoggerFactory.getLogger(TokenService.class); @@ -47,7 +52,12 @@ public class TokenService { 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); return Jwts.builder() .setSubject(user.getId()) @@ -63,6 +73,12 @@ public class TokenService { .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) { User user = userRepository.findByEmailWithRoles(credentials.email()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserAccountDeletionService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserAccountDeletionService.java new file mode 100644 index 0000000..84d219a --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserAccountDeletionService.java @@ -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()); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserService.java index 78a5082..cfd96f1 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserService.java @@ -236,6 +236,8 @@ public class UserService { if (userRepository.existsByEmail(payload.newEmail())) { 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( StringGenerator.randomString(127, StringGenerator.Alphabet.ALPHANUMERIC), payload.newEmail(), diff --git a/gymboard-app/src/api/main/auth.ts b/gymboard-app/src/api/main/auth.ts index 6343393..3f0e8aa 100644 --- a/gymboard-app/src/api/main/auth.ts +++ b/gymboard-app/src/api/main/auth.ts @@ -263,6 +263,14 @@ class AuthModule { const response = await api.get('/auth/me/roles', authStore.axiosConfig); return response.data; } + + public async requestAccountData(authStore: AuthStoreType): Promise { + await api.post('/auth/me/data-requests', null, authStore.axiosConfig); + } + + public async deleteAccount(authStore: AuthStoreType): Promise { + await api.delete('/auth/me', authStore.axiosConfig); + } } export default AuthModule; diff --git a/gymboard-app/src/boot/i18n.ts b/gymboard-app/src/boot/i18n.ts index b7fb451..1ea7dae 100644 --- a/gymboard-app/src/boot/i18n.ts +++ b/gymboard-app/src/boot/i18n.ts @@ -21,13 +21,13 @@ declare module 'vue-i18n' { } /* eslint-enable @typescript-eslint/no-empty-interface */ -export default boot(({ app }) => { - const i18n = createI18n({ - locale: 'en-US', - legacy: false, - messages, - }); +export const i18n = createI18n({ + locale: 'en-US', + legacy: false, + messages, +}); +export default boot(({ app }) => { // Set the locale to the preferred locale, if possible. const userLocale = window.navigator.language; if (userLocale === 'nl-NL') { diff --git a/gymboard-app/src/components/AccountMenuItem.vue b/gymboard-app/src/components/AccountMenuItem.vue index 24b407e..5226a54 100644 --- a/gymboard-app/src/components/AccountMenuItem.vue +++ b/gymboard-app/src/components/AccountMenuItem.vue @@ -17,7 +17,7 @@ account-related actions. {{ $t('accountMenuItem.profile') }} - + {{ $t('accountMenuItem.settings') }} diff --git a/gymboard-app/src/i18n/en-US/index.ts b/gymboard-app/src/i18n/en-US/index.ts index 7812b65..7f4675c 100644 --- a/gymboard-app/src/i18n/en-US/index.ts +++ b/gymboard-app/src/i18n/en-US/index.ts @@ -18,6 +18,11 @@ export default { register: 'Register', 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: { title: 'Login to Gymboard', email: 'Email', @@ -67,6 +72,7 @@ export default { userSettingsPage: { title: 'Account Settings', email: 'Email', + changeEmail: 'Change your email address', name: 'Name', password: 'Password', passwordHint: 'Set a new password for your account.', @@ -89,7 +95,33 @@ export default { language: 'Language' }, 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: { confirmDeletion: 'Confirm Deletion', @@ -108,5 +140,9 @@ export default { weightUnit: { kilograms: 'Kilograms', pounds: 'Pounds' + }, + confirm: { + title: 'Confirm', + message: 'Are you sure you want to continue?' } }; diff --git a/gymboard-app/src/i18n/nl-NL/index.ts b/gymboard-app/src/i18n/nl-NL/index.ts index 20d2613..1847ac8 100644 --- a/gymboard-app/src/i18n/nl-NL/index.ts +++ b/gymboard-app/src/i18n/nl-NL/index.ts @@ -62,6 +62,7 @@ export default { userSettingsPage: { title: 'Account instellingen', email: 'E-mail', + changeEmail: 'E-mail adres wijzigen', name: 'Naam', password: 'Wachtwoord', passwordHint: 'Stel een nieuw wachtwoord voor je account in.', diff --git a/gymboard-app/src/layouts/MainLayout.vue b/gymboard-app/src/layouts/MainLayout.vue index 742b83f..59cff00 100644 --- a/gymboard-app/src/layouts/MainLayout.vue +++ b/gymboard-app/src/layouts/MainLayout.vue @@ -57,7 +57,7 @@ diff --git a/gymboard-app/src/pages/SubmissionPage.vue b/gymboard-app/src/pages/SubmissionPage.vue index 0d963e3..051ecb9 100644 --- a/gymboard-app/src/pages/SubmissionPage.vue +++ b/gymboard-app/src/pages/SubmissionPage.vue @@ -41,7 +41,7 @@ import { DateTime } from 'luxon'; import { getFileUrl } from 'src/api/cdn'; import { getGymRoute } from 'src/router/gym-routing'; import {useAuthStore} from 'stores/auth-store'; -import {showApiErrorToast} from 'src/utils'; +import {confirm, showApiErrorToast} from 'src/utils'; import {useI18n} from 'vue-i18n'; import {useQuasar} from 'quasar'; @@ -69,11 +69,10 @@ onMounted(async () => { * the user back to their home page that shows all their lifts. */ async function deleteSubmission() { - quasar.dialog({ + confirm({ title: i18n.t('submissionPage.confirmDeletion'), - message: i18n.t('submissionPage.confirmDeletionMsg'), - cancel: true - }).onOk(async () => { + message: i18n.t('submissionPage.confirmDeletionMsg') + }).then(async () => { if (!submission.value) return; try { await api.gyms.submissions.deleteSubmission(submission.value.id, authStore); diff --git a/gymboard-app/src/pages/auth/DeleteAccountPage.vue b/gymboard-app/src/pages/auth/DeleteAccountPage.vue new file mode 100644 index 0000000..58bf534 --- /dev/null +++ b/gymboard-app/src/pages/auth/DeleteAccountPage.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/gymboard-app/src/pages/auth/RegistrationSuccessPage.vue b/gymboard-app/src/pages/auth/RegistrationSuccessPage.vue index b303212..b4004d4 100644 --- a/gymboard-app/src/pages/auth/RegistrationSuccessPage.vue +++ b/gymboard-app/src/pages/auth/RegistrationSuccessPage.vue @@ -1,8 +1,8 @@ diff --git a/gymboard-app/src/pages/auth/RequestAccountDataPage.vue b/gymboard-app/src/pages/auth/RequestAccountDataPage.vue new file mode 100644 index 0000000..10409ad --- /dev/null +++ b/gymboard-app/src/pages/auth/RequestAccountDataPage.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/gymboard-app/src/pages/auth/UpdateEmailPage.vue b/gymboard-app/src/pages/auth/UpdateEmailPage.vue new file mode 100644 index 0000000..32cca47 --- /dev/null +++ b/gymboard-app/src/pages/auth/UpdateEmailPage.vue @@ -0,0 +1,101 @@ + + + diff --git a/gymboard-app/src/pages/auth/UserSettingsPage.vue b/gymboard-app/src/pages/auth/UserSettingsPage.vue index a80adaf..b011f8e 100644 --- a/gymboard-app/src/pages/auth/UserSettingsPage.vue +++ b/gymboard-app/src/pages/auth/UserSettingsPage.vue @@ -3,7 +3,7 @@ The page where users can edit their personal information and preferences. -->