Add account deletion, data requests, and more.

This commit is contained in:
Andrew Lalis 2023-03-31 19:02:46 +02:00
parent 7a31ab5028
commit 9d1712889e
35 changed files with 681 additions and 71 deletions

View File

@ -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<Submission, String>, JpaSpecificationExecutor<Submission> {
@Modifying
void deleteAllByUser(User user);
}

View File

@ -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<String> getMyRoles(@AuthenticationPrincipal User myUser) {
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();
}
}

View File

@ -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<EmailResetCode, String> {
@Modifying
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
boolean existsByNewEmail(String newEmail);
@Modifying
void deleteByNewEmail(String newEmail);
@Modifying
void deleteAllByUser(User user);
}

View File

@ -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<PasswordResetCode, String> {
@Modifying
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
@Modifying
void deleteAllByUser(User user);
}

View File

@ -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);
}

View File

@ -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<UserActivati
@Modifying
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
@Modifying
void deleteAllByUser(User user);
}

View File

@ -14,6 +14,8 @@ public interface UserFollowingRepository extends JpaRepository<UserFollowing, Lo
@Modifying
void deleteByFollowedUserAndFollowingUser(User followedUser, User followingUser);
@Modifying
void deleteAllByFollowedUserOrFollowingUser(User followedUser, User followingUser);
Page<UserFollowing> findAllByFollowedUserOrderByCreatedAtDesc(User followedUser, Pageable pageable);
Page<UserFollowing> findAllByFollowingUserOrderByCreatedAtDesc(User followingUser, Pageable pageable);

View File

@ -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<UserReport, Long> {
@Modifying
void deleteAllByUserOrReportedBy(User user, User reportedBy);
}

View File

@ -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<User, String>, JpaSpecific
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email")
Optional<User> findByEmailWithRoles(String email);
@Modifying
void deleteAllByActivatedFalseAndCreatedAtBefore(LocalDateTime cutoff);
}

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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.
}

View File

@ -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));

View File

@ -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());
}
}

View File

@ -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(),

View File

@ -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<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;

View File

@ -21,13 +21,13 @@ declare module 'vue-i18n' {
}
/* eslint-enable @typescript-eslint/no-empty-interface */
export default boot(({ app }) => {
const i18n = createI18n({
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') {

View File

@ -17,7 +17,7 @@ account-related actions.
<q-item-label>{{ $t('accountMenuItem.profile') }}</q-item-label>
</q-item-section>
</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-label>{{ $t('accountMenuItem.settings') }}</q-item-label>
</q-item-section>

View File

@ -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?'
}
};

View File

@ -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.',

View File

@ -57,7 +57,7 @@
</template>
<script setup lang="ts">
import {onMounted, ref} from 'vue';
import {ref} from 'vue';
import AccountMenuItem from 'components/AccountMenuItem.vue';
import {useAuthStore} from 'stores/auth-store';
@ -67,8 +67,4 @@ const leftDrawerOpen = ref(false);
function toggleLeftDrawer() {
leftDrawerOpen.value = !leftDrawerOpen.value;
}
onMounted(async () => {
await authStore.tryLogInWithStoredToken();
});
</script>

View File

@ -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);

View File

@ -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>

View File

@ -1,8 +1,8 @@
<template>
<StandardCenteredPage>
<h3 class="text-center">{{ $t('registrationSuccessPage.title') }}</h3>
<p>Check your email for the link to activate your account.</p>
<p>You may safely close this page.</p>
<p>{{ $t('registrationSuccessPage.p1') }}</p>
<p>{{ $t('registrationSuccessPage.p2') }}</p>
</StandardCenteredPage>
</template>

View File

@ -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>

View File

@ -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>

View File

@ -3,7 +3,7 @@ The page where users can edit their personal information and preferences.
-->
<template>
<q-page>
<StandardCenteredPage v-if="authStore.loggedIn">
<StandardCenteredPage>
<h3>{{ $t('userSettingsPage.title') }}</h3>
<hr>
@ -11,6 +11,11 @@ The page where users can edit their personal information and preferences.
<span class="property-label">{{ $t('userSettingsPage.email') }}</span>
<q-input type="email" v-model="authStore.user.email" dense readonly/>
</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">
<span class="property-label">{{ $t('userSettingsPage.name') }}</span>
<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>
<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>
</q-page>
</template>
<script setup lang="ts">
import StandardCenteredPage from 'components/StandardCenteredPage.vue';
import {useRoute, useRouter} from 'vue-router';
import {useAuthStore} from 'stores/auth-store';
import {computed, onMounted, ref, Ref, toRaw} from 'vue';
import {UserPersonalDetails, UserPreferences} from 'src/api/main/auth';
@ -103,8 +117,6 @@ import {resolveLocale, supportedLocales} from 'src/i18n';
import {useI18n} from 'vue-i18n';
import {showApiErrorToast, showSuccessToast, showWarningToast} from 'src/utils';
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const i18n = useI18n({useScope: 'global'});
@ -117,8 +129,6 @@ let initialPreferences: UserPreferences | null = null;
const newPassword = ref('');
onMounted(async () => {
const userId = route.params.userId as string;
if (authStore.user && authStore.user.id === userId) {
personalDetails.value = await api.auth.getMyPersonalDetails(authStore);
initialPersonalDetails = structuredClone(toRaw(personalDetails.value));
@ -126,10 +136,6 @@ onMounted(async () => {
initialPreferences = structuredClone(toRaw(preferences.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(() => {

View File

@ -7,6 +7,7 @@ import {
} from 'vue-router';
import routes from './routes';
import {useAuthStore} from 'stores/auth-store';
/*
* If not building with SSR mode, you can
@ -24,7 +25,7 @@ export default route(function (/* { store, ssrContext } */) {
? createWebHistory
: createWebHashHistory;
const Router = createRouter({
const router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
@ -34,5 +35,16 @@ export default route(function (/* { store, ssrContext } */) {
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;
});

View File

@ -16,6 +16,9 @@ import UserSearchPage from 'pages/UserSearchPage.vue';
import AdminPage from 'pages/admin/AdminPage.vue';
import {useAuthStore} from 'stores/auth-store';
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[] = [
// Auth-related pages, which live outside the main layout.
@ -31,7 +34,6 @@ const routes: RouteRecordRaw[] = [
children: [
{ path: '', component: GymSearchPage },
{ path: 'users', component: UserSearchPage },
{ path: 'users/:userId/settings', component: UserSettingsPage },
{ // Match anything under /users/:userId to the UserPage, since it manages sub-pages manually.
path: 'users/:userId+',
component: UserPage
@ -53,6 +55,21 @@ const routes: RouteRecordRaw[] = [
}
},
{ 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 }
]
}
],
},

View File

@ -1,5 +1,5 @@
import {useQuasar} from 'quasar';
import {useI18n} from 'vue-i18n';
import {i18n} from 'boot/i18n';
import {Notify, Dialog, QDialogOptions} from 'quasar';
/**
* 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));
/**
* 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) {
const quasar = useQuasar();
const i18n = useI18n();
quasar.notify({
message: i18n.t(messageKey),
const { t } = i18n.global;
Notify.create({
message: t(messageKey),
type: type,
position: 'top'
});
@ -25,10 +50,10 @@ function showToast(type: string, messageKey: string) {
* @param error The error to display.
*/
export function showApiErrorToast(error?: unknown) {
showToast('danger', 'generalErrors.apiError');
if (error) {
console.error(error);
}
showToast('danger', 'generalErrors.apiError');
}
export function showInfoToast(messageKey: string) {

View File

@ -4,7 +4,7 @@
],
"copyright": "Copyright © 2023, Andrew Lalis",
"dependencies": {
"handy-httpd": "~>5.7.0",
"handy-httpd": "~>6.0.0",
"slf4d": "~>2.1.1"
},
"description": "Service for handling Gymboard file uploads.",

View File

@ -1,7 +1,7 @@
{
"fileVersion": 1,
"versions": {
"handy-httpd": "5.7.0",
"handy-httpd": "6.0.0",
"httparsed": "1.2.1",
"slf4d": "2.1.1"
}

View File

@ -10,6 +10,7 @@ void main() {
ctx.response.writeBodyString("online");
});
pathHandler.addMapping("POST", "/uploads", new VideoUploadHandler());
pathHandler.addMapping("POST", "/uploads/{uploadId}/process", new VideoProcessingHandler());
HttpServer server = new HttpServer(pathHandler, getServerConfig());
server.start();

View File

@ -1,25 +1,75 @@
module handlers;
import handy_httpd;
import slf4d;
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 {
public void handle(ref HttpRequestContext ctx) {
if ("Content-Length" !in ctx.request.headers) {
ctx.response.status = 411;
ctx.response.statusText = "Length Required";
return;
if (!validateHeaders(ctx)) return;
if (!exists(TEMP_UPLOADS_DIR)) mkdir(TEMP_UPLOADS_DIR);
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;
if (contentLength == 0 || contentLength > MAX_UPLOAD_SIZE) {
ctx.response.status = 413;
ctx.response.statusText = "Payload Too Large";
return;
private bool validateHeaders(ref HttpRequestContext ctx) {
ulong contentLength = ctx.request.getHeaderAs!ulong("Content-Length");
if (contentLength == 0) {
ctx.response.status = HttpStatus.LENGTH_REQUIRED;
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);
}
}