Client - a user can request and download data export archive

This commit is contained in:
Sam 2023-03-01 21:16:25 +01:00
parent 073c677b92
commit 67d7fc13b5
16 changed files with 207 additions and 7 deletions

View File

@ -62,13 +62,39 @@
<button class="danger" @click.prevent="updateDisplayModal(true)">
{{ $t('buttons.DELETE_MY_ACCOUNT') }}
</button>
<button class="confirm" v-if="canRequestExport()" @click.prevent="requestExport">
{{ $t('buttons.REQUEST_DATA_EXPORT') }}
</button>
</div>
</form>
<div class="data-export">
<span class="info-box">
<i class="fa fa-info-circle" aria-hidden="true" />
{{ $t('user.EXPORT_REQUEST.ONLY_ONE_EXPORT_PER_DAY') }}
</span>
<div v-if="exportRequest" class="data-export-archive">
{{$t('user.EXPORT_REQUEST.DATA_EXPORT')}}
({{ exportRequestDate }}):
<span
v-if="exportRequest.status=== 'successful'"
class="archive-link"
@click.prevent="downloadArchive(exportRequest.file_name)"
>
<i class="fa fa-download" aria-hidden="true" />
{{ $t("user.EXPORT_REQUEST.DOWNLOAD_ARCHIVE") }}
({{ getReadableFileSize(exportRequest.file_size) }})
</span>
<span v-else>
{{ $t(`user.EXPORT_REQUEST.STATUS.${exportRequest.status}`)}}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { isBefore, subDays } from 'date-fns'
import {
ComputedRef,
Ref,
@ -81,14 +107,17 @@
onUnmounted,
} from 'vue'
import authApi from "@/api/authApi";
import PasswordInput from '@/components/Common/PasswordInput.vue'
import { AUTH_USER_STORE, ROOT_STORE } from '@/store/constants'
import { TAppConfig } from '@/types/application'
import { IUserProfile, IUserAccountPayload } from '@/types/user'
import {IAuthUserProfile, IUserAccountPayload, IExportRequest} from '@/types/user'
import { useStore } from '@/use/useStore'
import { formatDate } from '@/utils/dates'
import { getReadableFileSize } from '@/utils/files'
interface Props {
user: IUserProfile
user: IAuthUserProfile
}
const props = defineProps<Props>()
const { user } = toRefs(props)
@ -114,9 +143,16 @@
)
const formErrors = ref(false)
const displayModal: Ref<boolean> = ref(false)
const exportRequest: ComputedRef<IExportRequest | null> = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.EXPORT_REQUEST]
)
const exportRequestDate: ComputedRef<string | null> = computed(
() => getExportRequestDate()
)
onMounted(() => {
if (props.user) {
store.dispatch(AUTH_USER_STORE.ACTIONS.GET_REQUEST_DATA_EXPORT)
updateUserForm(props.user)
}
})
@ -124,7 +160,7 @@
function invalidateForm() {
formErrors.value = true
}
function updateUserForm(user: IUserProfile) {
function updateUserForm(user: IAuthUserProfile) {
userForm.email = user.email
}
function updatePassword(password: string) {
@ -133,6 +169,21 @@
function updateNewPassword(new_password: string) {
userForm.new_password = new_password
}
function getExportRequestDate() {
return exportRequest.value ? formatDate(
exportRequest.value.created_at,
user.value.timezone,
user.value.date_format,
true,
null, true
) : null
}
function canRequestExport() {
return exportRequestDate.value
? isBefore(new Date(exportRequestDate.value), subDays(new Date(), 1))
: true
}
function updateProfile() {
const payload: IUserAccountPayload = {
email: userForm.email,
@ -150,6 +201,25 @@
function deleteAccount(username: string) {
store.dispatch(AUTH_USER_STORE.ACTIONS.DELETE_ACCOUNT, { username })
}
function requestExport() {
store.dispatch(AUTH_USER_STORE.ACTIONS.REQUEST_DATA_EXPORT)
}
async function downloadArchive(filename: string) {
await authApi
.get(`/auth/profile/export/${filename}`, {
responseType: 'blob',
})
.then((response) => {
const archiveFileUrl = window.URL.createObjectURL(
new Blob([response.data], { type: 'application/zip' })
)
const archive_link = document.createElement('a')
archive_link.href = archiveFileUrl
archive_link.setAttribute('download', filename)
document.body.appendChild(archive_link)
archive_link.click()
})
}
onUnmounted(() => {
store.commit(AUTH_USER_STORE.MUTATIONS.UPDATE_IS_SUCCESS, false)
@ -203,4 +273,17 @@
flex-direction: column;
}
}
.data-export {
padding: $default-padding 0;
.data-export-archive {
padding-top: $default-padding*2;
font-size: .9em;
.archive-link {
color: var(--app-a-color);
cursor: pointer;
}
}
}
</style>

View File

@ -3,6 +3,7 @@
"Network Error": "Network Error.",
"UNKNOWN": "Error. Please try again or contact the administrator.",
"at least one file in zip archive exceeds size limit, please check the archive": "At least one file in zip archive exceeds size limit, please check the archive.",
"completed request already exists": "A completed export request already exists.",
"email: valid email must be provided": "Email: valid email must be provided.",
"error during gpx file parsing": "Error during gpx file parsing.",
"error during gpx processing": "Error during gpx processing.",
@ -19,6 +20,7 @@
"new email must be different than curent email": "The new email must be different than curent email",
"no file part": "No file provided.",
"no selected file": "No selected file.",
"ongoing request exists": "A data export request already exists.",
"password: password and password confirmation do not match": "Password: password and password confirmation don't match.",
"provide a valid auth token": "Provide a valid auth token.",
"signature expired, please log in again": "Signature expired. Please log in again.",

View File

@ -12,6 +12,7 @@
"LOGIN": "Log in",
"NO": "No",
"REGISTER": "Register",
"REQUEST_DATA_EXPORT": "Request data export",
"RESET": "Reset",
"SUBMIT": "Submit",
"YES": "Yes"

View File

@ -8,6 +8,15 @@
"EMAIL": "Email",
"EMAIL_INFO": "Enter a valid email address.",
"ENTER_PASSWORD": "Enter a password",
"EXPORT_REQUEST": {
"DATA_EXPORT": "Data export",
"DOWNLOAD_ARCHIVE": "Download archive",
"ONLY_ONE_EXPORT_PER_DAY": "You can request an archive by 24 hours",
"STATUS": {
"errored": "errored (please request another export)",
"in_progress": "in progres..."
}
},
"FILTER_ON_USERNAME": "Filter on username",
"HIDE_PASSWORD": "hide password",
"I_WANT_TO_DELETE_MY_ACCOUNT": "I want to delete my account",

View File

@ -3,6 +3,7 @@
"Network Error": "Erreur réseau.",
"UNKNOWN": "Erreur. Veuillez réessayer ou contacter l'administrateur.",
"at least one file in zip archive exceeds size limit, please check the archive": "Au moins un fichier de l'archive zip dépasse la taille maximale, veuillez vérifier l'archive.",
"completed request already exists": "Une demande d'export terminée existe déjà.",
"email: valid email must be provided": "Courriel : une adresse électronique valide doit être fournie.",
"error during gpx file parsing": "Erreur lors de l'analyse du fichier.",
"error during gpx processing": "Erreur lors du traitement du fichier gpx.",
@ -19,6 +20,7 @@
"new email must be different than curent email": "La nouvelle addresse électronique doit être differente de l'adresse actuelle",
"no file part": "Pas de fichier fourni.",
"no selected file": "Pas de fichier sélectionné.",
"ongoing request exists": "Une demande d'export de données est en cours",
"password: password and password confirmation do not match": "Mot de passe : les mots de passe saisis sont différents.",
"provide a valid auth token": "Merci de fournir un jeton de connexion valide.",
"signature expired, please log in again": "Signature expirée. Merci de vous reconnecter.",

View File

@ -12,6 +12,7 @@
"LOGIN": "Se connecter",
"NO": "Non",
"REGISTER": "S'inscrire",
"REQUEST_DATA_EXPORT": "Demander un export de données",
"RESET": "Réinit.",
"SUBMIT": "Valider",
"YES": "Oui"

View File

@ -8,6 +8,15 @@
"EMAIL": "Courriel",
"EMAIL_INFO": "Saisissez une adresse électronique valide.",
"ENTER_PASSWORD": "Saisissez un mot de passe",
"EXPORT_REQUEST": {
"DATA_EXPORT": "Export des données",
"DOWNLOAD_ARCHIVE": "Télécharger l'archive",
"ONLY_ONE_EXPORT_PER_DAY": "Vous pouvez demander un export par 24h",
"STATUS": {
"errored": "en erreur (veuillez demander une nouvelle archive)",
"in_progress": "en cours..."
}
},
"FILTER_ON_USERNAME": "Filtrer sur le nom d'utilisateur",
"HIDE_PASSWORD": "masquer le mot de passe",
"I_WANT_TO_DELETE_MY_ACCOUNT": "Je souhaite supprimer mon compte",

View File

@ -447,4 +447,40 @@ export const actions: ActionTree<IAuthUserState, IRootState> &
})
.catch((error) => handleError(context, error))
},
[AUTH_USER_STORE.ACTIONS.REQUEST_DATA_EXPORT](
context: ActionContext<IAuthUserState, IRootState>
): void {
context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
authApi
.post('auth/profile/export/request')
.then((res) => {
if (res.data.status === 'success') {
context.commit(
AUTH_USER_STORE.MUTATIONS.SET_EXPORT_REQUEST,
res.data.request
)
} else {
handleError(context, null)
}
})
.catch((error) => handleError(context, error))
},
[AUTH_USER_STORE.ACTIONS.GET_REQUEST_DATA_EXPORT](
context: ActionContext<IAuthUserState, IRootState>
): void {
context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
authApi
.get('auth/profile/export')
.then((res) => {
if (res.data.status === 'success') {
context.commit(
AUTH_USER_STORE.MUTATIONS.SET_EXPORT_REQUEST,
res.data.request
)
} else {
handleError(context, null)
}
})
.catch((error) => handleError(context, error))
},
}

View File

@ -5,13 +5,15 @@ export enum AuthUserActions {
CONFIRM_EMAIL = 'CONFIRM_EMAIL',
DELETE_ACCOUNT = 'DELETE_ACCOUNT',
DELETE_PICTURE = 'DELETE_PICTURE',
GET_REQUEST_DATA_EXPORT = 'GET_REQUEST_DATA_EXPORT',
GET_USER_PROFILE = 'GET_USER_PROFILE',
LOGIN_OR_REGISTER = 'LOGIN_OR_REGISTER',
LOGOUT = 'LOGOUT',
SEND_PASSWORD_RESET_REQUEST = 'SEND_PASSWORD_RESET_REQUEST',
REQUEST_DATA_EXPORT = 'REQUEST_DATA_EXPORT',
RESEND_ACCOUNT_CONFIRMATION_EMAIL = 'RESEND_ACCOUNT_CONFIRMATION_EMAIL',
RESET_USER_PASSWORD = 'RESET_USER_PASSWORD',
RESET_USER_SPORT_PREFERENCES = 'RESET_USER_SPORT_PREFERENCES',
SEND_PASSWORD_RESET_REQUEST = 'SEND_PASSWORD_RESET_REQUEST',
UPDATE_USER_ACCOUNT = 'UPDATE_USER_ACCOUNT',
UPDATE_USER_PICTURE = 'UPDATE_USER_PICTURE',
UPDATE_USER_PROFILE = 'UPDATE_USER_PROFILE',
@ -27,6 +29,7 @@ export enum AuthUserGetters {
IS_SUCCESS = 'IS_SUCCESS',
IS_REGISTRATION_SUCCESS = 'IS_REGISTRATION_SUCCESS',
USER_LOADING = 'USER_LOADING',
EXPORT_REQUEST = 'EXPORT_REQUEST',
}
export enum AuthUserMutations {
@ -36,4 +39,5 @@ export enum AuthUserMutations {
UPDATE_IS_SUCCESS = 'UPDATE_USER_IS_SUCCESS',
UPDATE_IS_REGISTRATION_SUCCESS = 'UPDATE_IS_REGISTRATION_SUCCESS',
UPDATE_USER_LOADING = 'UPDATE_USER_LOADING',
SET_EXPORT_REQUEST = 'SET_EXPORT_REQUEST',
}

View File

@ -15,6 +15,9 @@ export const getters: GetterTree<IAuthUserState, IRootState> &
[AUTH_USER_STORE.GETTERS.AUTH_USER_PROFILE]: (state: IAuthUserState) => {
return state.authUserProfile
},
[AUTH_USER_STORE.GETTERS.EXPORT_REQUEST]: (state: IAuthUserState) => {
return state.exportRequest
},
[AUTH_USER_STORE.GETTERS.IS_AUTHENTICATED]: (state: IAuthUserState) => {
return state.authToken !== null
},

View File

@ -5,7 +5,7 @@ import {
IAuthUserState,
TAuthUserMutations,
} from '@/store/modules/authUser/types'
import { IAuthUserProfile } from '@/types/user'
import { IAuthUserProfile, IExportRequest } from '@/types/user'
export const mutations: MutationTree<IAuthUserState> & TAuthUserMutations = {
[AUTH_USER_STORE.MUTATIONS.CLEAR_AUTH_USER_TOKEN](state: IAuthUserState) {
@ -42,4 +42,10 @@ export const mutations: MutationTree<IAuthUserState> & TAuthUserMutations = {
) {
state.loading = loading
},
[AUTH_USER_STORE.MUTATIONS.SET_EXPORT_REQUEST](
state: IAuthUserState,
exportRequest: IExportRequest
) {
state.exportRequest = exportRequest
},
}

View File

@ -7,4 +7,5 @@ export const authUserState: IAuthUserState = {
isSuccess: false,
isRegistrationSuccess: false,
loading: false,
exportRequest: null,
}

View File

@ -19,6 +19,7 @@ import {
IUserSportPreferencesPayload,
IUserAccountPayload,
IUserAccountUpdatePayload,
IExportRequest,
} from '@/types/user'
export interface IAuthUserState {
@ -27,6 +28,7 @@ export interface IAuthUserState {
isRegistrationSuccess: boolean
isSuccess: boolean
loading: boolean
exportRequest: IExportRequest | null
}
export interface IAuthUserActions {
@ -115,6 +117,14 @@ export interface IAuthUserActions {
context: ActionContext<IAuthUserState, IRootState>,
acceptedPolicy: boolean
): void
[AUTH_USER_STORE.ACTIONS.REQUEST_DATA_EXPORT](
context: ActionContext<IAuthUserState, IRootState>
): void
[AUTH_USER_STORE.ACTIONS.GET_REQUEST_DATA_EXPORT](
context: ActionContext<IAuthUserState, IRootState>
): void
}
export interface IAuthUserGetters {
@ -124,6 +134,10 @@ export interface IAuthUserGetters {
state: IAuthUserState
): IAuthUserProfile
[AUTH_USER_STORE.GETTERS.EXPORT_REQUEST](
state: IAuthUserState
): IExportRequest | null
[AUTH_USER_STORE.GETTERS.IS_ADMIN](state: IAuthUserState): boolean
[AUTH_USER_STORE.GETTERS.IS_AUTHENTICATED](state: IAuthUserState): boolean
@ -139,6 +153,10 @@ export interface IAuthUserGetters {
export type TAuthUserMutations<S = IAuthUserState> = {
[AUTH_USER_STORE.MUTATIONS.CLEAR_AUTH_USER_TOKEN](state: S): void
[AUTH_USER_STORE.MUTATIONS.SET_EXPORT_REQUEST](
state: S,
exportRequest: IExportRequest | null
): void
[AUTH_USER_STORE.MUTATIONS.UPDATE_AUTH_TOKEN](
state: S,
authToken: string

View File

@ -108,3 +108,10 @@ export interface ILoginOrRegisterData {
formData: ILoginRegisterFormData
redirectUrl?: string | null | LocationQueryValue[]
}
export interface IExportRequest {
created_at: string
status: string
file_name: string
file_size: number
}

View File

@ -111,14 +111,16 @@ export const formatDate = (
timezone: string,
dateFormat: string,
withTime = true,
language: string | null = null
language: string | null = null,
withSeconds = false
): string => {
if (!language) {
language = locale.value
}
const timeFormat = withTime ? (withSeconds ? ' HH:mm:ss' : ' HH:mm') : ''
return format(
getDateWithTZ(dateString, timezone),
`${getDateFormat(dateFormat, language)}${withTime ? ' HH:mm' : ''}`,
`${getDateFormat(dateFormat, language)}${timeFormat}`,
{ locale: localeFromLanguage[language] }
)
}

View File

@ -293,6 +293,22 @@ describe('formatDate (w/ default value)', () => {
})
})
describe('formatDate with_seconds', () => {
it('format date for "Europe/Paris" timezone and "dd/MM/yyyy" format and seconds', () => {
assert.deepEqual(
formatDate(
'Tue, 01 Nov 2022 00:00:00 GMT',
'Europe/Paris',
'yyyy-MM-dd',
true,
null,
true
),
'2022-11-01 01:00:00'
)
})
})
describe('getDateFormat', () => {
const testsParams = [
{