0
1
Fork 0

Merge pull request 'Account config and change password form' (!73) from wbuezas/hedera-web-mindshore:feature/AccountConfig into 4922-vueMigration

Reviewed-on: verdnatura/hedera-web#73
Reviewed-by: Javier Segarra <jsegarra@verdnatura.es>
This commit is contained in:
Javier Segarra 2024-07-26 20:24:41 +00:00
commit 24687e57e6
11 changed files with 535 additions and 41 deletions

View File

@ -2,7 +2,9 @@ import { boot } from 'quasar/wrappers';
import { Connection } from '../js/db/connection'; import { Connection } from '../js/db/connection';
import { userStore } from 'stores/user'; import { userStore } from 'stores/user';
import axios from 'axios'; import axios from 'axios';
import useNotify from 'src/composables/useNotify.js';
const { notify } = useNotify();
// Be careful when using SSR for cross-request state pollution // Be careful when using SSR for cross-request state pollution
// due to creating a Singleton instance here; // due to creating a Singleton instance here;
// If any client changes this (global) instance, it might be a // If any client changes this (global) instance, it might be a
@ -12,9 +14,27 @@ import axios from 'axios';
const api = axios.create({ const api = axios.create({
baseURL: `//${location.hostname}:${location.port}/api/` baseURL: `//${location.hostname}:${location.port}/api/`
}); });
const jApi = new Connection(); const jApi = new Connection();
const onRequestError = error => {
return Promise.reject(error);
};
const onResponseError = error => {
let message = '';
const response = error.response;
const responseData = response && response.data;
const responseError = responseData && response.data.error;
if (responseError) {
message = responseError.message;
}
notify(message, 'negative');
return Promise.reject(error);
};
export default boot(({ app }) => { export default boot(({ app }) => {
const user = userStore(); const user = userStore();
function addToken(config) { function addToken(config) {
@ -23,7 +43,9 @@ export default boot(({ app }) => {
} }
return config; return config;
} }
api.interceptors.request.use(addToken); api.interceptors.request.use(addToken, onRequestError);
api.interceptors.response.use(response => response, onResponseError);
jApi.use(addToken); jApi.use(addToken);
// for use inside Vue files (Options API) through this.$axios and this.$api // for use inside Vue files (Options API) through this.$axios and this.$api

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, inject, onMounted, computed } from 'vue'; import { ref, inject, onMounted, computed, Teleport } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import useNotify from 'src/composables/useNotify.js'; import useNotify from 'src/composables/useNotify.js';
@ -65,6 +65,14 @@ const props = defineProps({
defaultActions: { defaultActions: {
type: Boolean, type: Boolean,
default: true default: true
},
showBottomActions: {
type: Boolean,
default: false
},
saveFn: {
type: Function,
default: null
} }
}); });
@ -91,6 +99,8 @@ const updatedColumns = computed(() => {
); );
}); });
const hasChanges = computed(() => !!updatedColumns.value.length);
const fetchFormData = async () => { const fetchFormData = async () => {
if (!props.fetchFormDataSql.query) return; if (!props.fetchFormDataSql.query) return;
loading.value = true; loading.value = true;
@ -113,15 +123,26 @@ const fetchFormData = async () => {
loading.value = false; loading.value = false;
}; };
const onSubmitSuccess = () => {
emit('onDataSaved');
notify(t('dataSaved'), 'positive');
};
const submit = async () => { const submit = async () => {
try { try {
const sqlQuery = generateSqlQuery(); if (props.saveFn) {
await jApi.execQuery(sqlQuery, props.pks); await props.saveFn(formData.value);
emit('onDataSaved'); } else {
notify(t('dataSaved'), 'positive'); if (!hasChanges.value) {
return;
}
const sqlQuery = generateSqlQuery();
await jApi.execQuery(sqlQuery, props.pks);
modelInfo.value.data[0] = { ...formData.value };
}
onSubmitSuccess();
} catch (error) { } catch (error) {
console.error('Error updating address:', error); console.error('Error:', error);
notify(t('addressNotUpdated'), 'negative');
} }
}; };
@ -158,19 +179,7 @@ defineExpose({
</script> </script>
<template> <template>
<Teleport :to="$actions"> <QCard class="form-container" v-bind="$attrs">
<QBtn
v-if="defaultActions"
:label="t('save')"
type="submit"
icon="check"
rounded
no-caps
:disabled="!updatedColumns.length"
@click="addressFormRef.submit()"
/>
</Teleport>
<QCard class="form-container">
<QForm <QForm
v-if="!loading" v-if="!loading"
ref="addressFormRef" ref="addressFormRef"
@ -181,6 +190,33 @@ defineExpose({
{{ title }} {{ title }}
</span> </span>
<slot name="form" :data="formData" /> <slot name="form" :data="formData" />
<component
:is="showBottomActions ? 'div' : Teleport"
:to="$actions"
class="flex row justify-end q-gutter-x-sm"
:class="{ 'q-mt-md': showBottomActions }"
>
<QBtn
v-if="defaultActions"
:label="t('cancel')"
:icon="showBottomActions ? undefined : 'check'"
rounded
no-caps
flat
v-close-popup
/>
<QBtn
v-if="defaultActions"
:label="t('save')"
type="submit"
:icon="showBottomActions ? undefined : 'check'"
rounded
no-caps
flat
:disabled="!showBottomActions && !updatedColumns.length"
/>
<slot name="actions" />
</component>
</QForm> </QForm>
<QSpinner v-else color="primary" size="3em" :thickness="2" /> <QSpinner v-else color="primary" size="3em" :thickness="2" />
</QCard> </QCard>

View File

@ -0,0 +1,273 @@
<script setup>
import { ref, inject, onMounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import VnInput from 'src/components/common/VnInput.vue';
import VnForm from 'src/components/common/VnForm.vue';
import { userStore as useUserStore } from 'stores/user';
import useNotify from 'src/composables/useNotify.js';
const props = defineProps({
verificationToken: {
type: String,
default: ''
}
});
const emit = defineEmits(['onPasswordChanged']);
const showOldPwd = ref(false);
const showNewPwd = ref(false);
const showCopyPwd = ref(false);
const { t } = useI18n();
const api = inject('api');
const userStore = useUserStore();
const { notify } = useNotify();
const oldPasswordRef = ref(null);
const newPasswordRef = ref(null);
const passwordRequirementsDialogRef = ref(null);
const vnFormRef = ref(null);
const repeatPassword = ref('');
const passwordRequirements = ref(null);
const formData = ref({
userId: userStore.id,
oldPassword: '',
newPassword: ''
});
const changePassword = async () => {
if (!formData.value.newPassword || !repeatPassword.value) {
notify(t('passwordEmpty'), 'negative');
throw new Error('Password empty');
}
if (formData.value.newPassword !== repeatPassword.value) {
notify(t('passwordsDoNotMatch'), 'negative');
throw new Error('Passwords do not match');
}
if (props.verificationToken) {
await changePasswordWithToken();
} else {
await changePasswordWithoutToken();
}
};
const changePasswordWithToken = async () => {
const headers = {
Authorization: props.verificationToken
};
await api.post('VnUsers/reset-password', formData.value, { headers });
};
const changePasswordWithoutToken = async () => {
await api.patch('Accounts/change-password', formData.value);
};
const getPasswordRequirements = async () => {
try {
const { data } = await api.get('UserPasswords/findOne');
passwordRequirements.value = data;
} catch (error) {
console.error(error);
}
};
const login = async () => {
await userStore.login(userStore.name, formData.value.newPassword);
};
const onPasswordChanged = async () => {
await login();
emit('onPasswordChanged');
};
onMounted(async () => {
getPasswordRequirements();
await nextTick();
if (props.verificationToken) {
newPasswordRef.value.focus();
} else {
oldPasswordRef.value.focus();
}
});
</script>
<template>
<VnForm
ref="vnFormRef"
:title="t('changePassword')"
:formInitialData="formData"
:saveFn="changePassword"
showBottomActions
:defaultActions="false"
style="max-width: 300px"
@onDataSaved="onPasswordChanged()"
>
<template #form>
<VnInput
v-if="!verificationToken"
ref="oldPasswordRef"
v-model="formData.oldPassword"
:type="!showOldPwd ? 'password' : 'text'"
:label="t('oldPassword')"
>
<template #append>
<QIcon
:name="showOldPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showOldPwd = !showOldPwd"
/>
</template>
</VnInput>
<VnInput
ref="newPasswordRef"
v-model="formData.newPassword"
:type="!showNewPwd ? 'password' : 'text'"
:label="t('newPassword')"
><template #append>
<QIcon
:name="showNewPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showNewPwd = !showNewPwd"
/>
</template></VnInput>
<VnInput
v-model="repeatPassword"
:type="!showCopyPwd ? 'password' : 'text'"
:label="t('repeatPassword')"
><template #append>
<QIcon
:name="showCopyPwd ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="showCopyPwd = !showCopyPwd"
/>
</template></VnInput>
</template>
<template #actions>
<QBtn
:label="t('requirements')"
rounded
no-caps
flat
@click="passwordRequirementsDialogRef.show()"
/>
<QBtn :label="t('modify')" type="submit" rounded no-caps flat />
</template>
</VnForm>
<QDialog ref="passwordRequirementsDialogRef">
<QCard class="q-px-md q-py-lg column items-center">
<span class="text-h6 text-bold q-mb-md">
{{ t('passwordRequirements') }}
</span>
<div class="column" style="max-width: max-content">
<span>
{{
t('charactersLong', {
length: passwordRequirements.length
})
}}
</span>
<span>
{{
t('alphabeticCharacters', {
nAlpha: passwordRequirements.nAlpha
})
}}
</span>
<span>
{{
t('capitalLetters', {
nUpper: passwordRequirements.nUpper
})
}}
</span>
<span>
{{ t('digits', { nDigits: passwordRequirements.nDigits }) }}
</span>
<span>
{{ t('symbols', { nPunct: passwordRequirements.nPunct }) }}
</span>
</div>
</QCard>
</QDialog>
</template>
<i18n lang="yaml">
en-US:
changePassword: Change password
newPassword: New password
oldPassword: Old password
repeatPassword: Repeat password
modify: Modify
requirements: Requirements
passwordRequirements: Password requirements
charactersLong: '{length} characters long'
alphabeticCharacters: '{nAlpha} alphabetic characters'
capitalLetters: '{nUpper} capital letters'
digits: '{nDigits} digits'
symbols: '{nPunct} symbols. Ej: $%&.'
passwordsDoNotMatch: Passwords do not match
passwordEmpty: Password empty
es-ES:
changePassword: Cambiar contraseña
newPassword: Nueva contraseña
oldPassword: Contraseña antigua
repeatPassword: Repetir contraseña
modify: Modificar
requirements: Requisitos
passwordRequirements: Requisitos de contraseña
charactersLong: '{length} caracteres de longitud'
alphabeticCharacters: '{nAlpha} caracteres alfabéticos'
capitalLetters: '{nUpper} letras mayúsculas'
digits: '{nDigits} dígitos'
symbols: '{nPunct} símbolos. Ej: $%&.'
passwordsDoNotMatch: ¡Las contraseñas no coinciden!
passwordEmpty: Contraseña vacía
ca-ES:
changePassword: Canviar contrasenya
newPassword: Nova contrasenya
oldPassword: Contrasenya antiga
repeatPassword: Repetir contrasenya
modify: Modificar
requirements: Requisits
passwordRequirements: Requisits de contrasenya
charactersLong: '{length} caràcters de longitud'
alphabeticCharacters: '{nAlpha} caràcters alfabètics'
capitalLetters: '{nUpper} lletres majúscules'
digits: '{nDigits} dígits'
symbols: '{nPunct} símbols. Ej: $%&.'
passwordsDoNotMatch: Les contrasenyes no coincideixen!
passwordEmpty: Contrasenya buida
fr-FR:
changePassword: Changer le mot de passe
newPassword: Nouveau mot de passe
oldPassword: Ancien mot de passe
repeatPassword: Répéter le mot de passe
modify: Modifier
requirements: Exigences
passwordRequirements: Mot de passe exigences
charactersLong: '{length} caractères de longueur'
alphabeticCharacters: '{nAlpha} caractères alphabétiques'
capitalLetters: '{nUpper} lettres majuscules'
digits: '{nDigits} chiffres'
symbols: '{nPunct} symboles. Ej: $%&.'
passwordsDoNotMatch: Les mots de passe ne correspondent pas!
passwordEmpty: Mots de passe vides
pt-PT:
changePassword: Alterar palavra-passe
newPassword: Nova palavra-passe
oldPassword: Palavra-passe antiga
repeatPassword: Repetir palavra-passe
modify: Modificar
requirements: Requisitos
passwordRequirements: Requisitos de palavra-passe
charactersLong: '{length} caracteres de comprimento'
alphabeticCharacters: '{nAlpha} caracteres alfabéticos'
capitalLetters: '{nUpper} letras maiúsculas'
digits: '{nDigits} dígitos'
symbols: '{nPunct} símbolos. Ej: $%&.'
passwordsDoNotMatch: As palavras-passe não coincidem!
passwordEmpty: Palavra-passe vazia
</i18n>

View File

@ -1,8 +1,8 @@
// app global css in SCSS form // app global css in SCSS form
@font-face { @font-face {
font-family: Poppins; font-family: Poppins;
src: url(./poppins.ttf) format('truetype'); src: url(./poppins.ttf) format('truetype');
} }
@font-face { @font-face {
font-family: 'Open Sans'; font-family: 'Open Sans';

View File

@ -75,5 +75,6 @@ export default {
addresses: 'Addresses', addresses: 'Addresses',
addressEdit: 'Edit address', addressEdit: 'Edit address',
dataSaved: 'Data saved', dataSaved: 'Data saved',
save: 'Save' save: 'Save',
cancel: 'Cancel'
}; };

View File

@ -93,5 +93,6 @@ export default {
addresses: 'Direcciones', addresses: 'Direcciones',
addressEdit: 'Editar dirección', addressEdit: 'Editar dirección',
dataSaved: 'Datos guardados', dataSaved: 'Datos guardados',
save: 'Guardar' save: 'Guardar',
cancel: 'Cancelar'
}; };

View File

@ -1,3 +1,13 @@
const sanitizeValue = value => {
if (typeof value === 'string') {
return `'${value}'`;
} else if (value === null) {
return 'NULL';
}
return value;
};
export const generateUpdateSqlQuery = ( export const generateUpdateSqlQuery = (
schema, schema,
table, table,
@ -6,7 +16,7 @@ export const generateUpdateSqlQuery = (
formData formData
) => { ) => {
const setClauses = columnsUpdated const setClauses = columnsUpdated
.map(colName => `${colName} = '${formData[colName]}'`) .map(colName => `${colName} = ${sanitizeValue(formData[colName])}`)
.join(', '); .join(', ');
const whereClause = Object.keys(pks) const whereClause = Object.keys(pks)
.map(pk => `${pk} = ${pks[pk]}`) .map(pk => `${pk} = ${pks[pk]}`)
@ -30,7 +40,7 @@ export const generateInsertSqlQuery = (
const columns = [createModelDefault.field, ...columnsUpdated].join(', '); const columns = [createModelDefault.field, ...columnsUpdated].join(', ');
const values = [ const values = [
createModelDefault.value, createModelDefault.value,
...columnsUpdated.map(colName => `'${formData[colName]}'`) ...columnsUpdated.map(colName => sanitizeValue(formData[colName]))
].join(', '); ].join(', ');
return ` return `

View File

@ -1,9 +0,0 @@
<script setup></script>
<template>
<QPage> // TODO: VISTA A DESARROLLAR! </QPage>
</template>
<style lang="scss" scoped></style>
<i18n lang="yaml"></i18n>

View File

@ -0,0 +1,159 @@
<script setup>
import { ref, inject, onMounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import VnInput from 'src/components/common/VnInput.vue';
import VnSelect from 'src/components/common/VnSelect.vue';
import VnForm from 'src/components/common/VnForm.vue';
import ChangePasswordForm from 'src/components/ui/ChangePasswordForm.vue';
import { userStore as useUserStore } from 'stores/user';
const userStore = useUserStore();
const { t } = useI18n();
const jApi = inject('jApi');
const vnFormRef = ref(null);
const changePasswordFormDialog = ref(null);
const showChangePasswordForm = ref(false);
const langOptions = ref([]);
const pks = computed(() => ({ id: userStore.id }));
const fetchConfigDataSql = {
query: `
SELECT u.id, u.name, u.email, u.nickname,
u.lang, c.isToBeMailed, c.id clientFk
FROM account.myUser u
LEFT JOIN myClient c
ON u.id = c.id`,
params: {}
};
const fetchLanguagesSql = async () => {
try {
const data = await jApi.query(
'SELECT code, name FROM language WHERE isActive'
);
langOptions.value = data;
} catch (error) {
console.error(error);
}
};
onMounted(() => fetchLanguagesSql());
</script>
<template>
<QPage>
<QPage class="q-pa-md flex justify-center">
<Teleport :to="$actions">
<QBtn
:label="t('addresses')"
icon="location_on"
rounded
no-caps
:to="{ name: 'AddressesList' }"
/>
<QBtn
:label="t('changePassword')"
icon="lock_reset"
rounded
no-caps
@click="showChangePasswordForm = true"
/>
</Teleport>
<VnForm
ref="vnFormRef"
:title="t('personalInformation')"
:fetchFormDataSql="fetchConfigDataSql"
:pks="pks"
table="myUser"
schema="account"
:defaultActions="false"
>
<template #form="{ data }">
<VnInput
v-model="data.name"
:label="t('name')"
disable
:clearable="false"
/>
<VnInput
v-model="data.email"
:label="t('email')"
@keyup.enter="vnFormRef.submit()"
@blur="vnFormRef.submit()"
/>
<VnInput
v-model="data.nickname"
:label="t('nickname')"
@keyup.enter="vnFormRef.submit()"
@blur="vnFormRef.submit()"
/>
<VnSelect
v-model="data.lang"
:label="t('lang')"
option-label="name"
option-value="code"
:options="langOptions"
@update:modelValue="vnFormRef.submit()"
/>
</template>
</VnForm>
</QPage>
<QDialog
ref="changePasswordFormDialog"
v-model="showChangePasswordForm"
>
<ChangePasswordForm
@on-password-changed="showChangePasswordForm = false"
/>
</QDialog>
</QPage>
</template>
<i18n lang="yaml">
en-US:
personalInformation: Personal Information
name: Name
email: Email
nickname: Display name
lang: Language
receiveInvoicesByMail: Receive invoices by mail
addresses: Addresses
changePassword: Change password
es-ES:
personalInformation: Datos personales
name: Nombre
email: Correo electrónico
nickname: Nombre a mostrar
lang: Idioma
receiveInvoicesByMail: Recibir facturas por correo
addresses: Direcciones
changePassword: Cambiar contraseña
ca-ES:
personalInformation: Dades personals
name: Nom
email: Correu electrònic
nickname: Nom a mostrar
lang: Idioma
receiveInvoicesByMail: Rebre factures per correu
addresses: Adreces
changePassword: Canviar contrasenya
fr-FR:
personalInformation: Informations personnelles
name: Nom
email: E-mail
nickname: Nom à afficher
lang: Langue
receiveInvoicesByMail: Recevoir des factures par courrier
addresses: Adresses
changePassword: Changer le mot de passe
pt-PT:
personalInformation: Dados pessoais
name: Nome
email: E-mail
nickname: Nom à afficher
lang: Língua
receiveInvoicesByMail: Receber faturas por correio
addresses: Endereços
changePassword: Alterar palavra-passe
</i18n>

View File

@ -4,7 +4,7 @@ const routes = [
component: () => import('layouts/LoginLayout.vue'), component: () => import('layouts/LoginLayout.vue'),
children: [ children: [
{ {
name: 'login', name: 'Login',
path: '/login/:email?', path: '/login/:email?',
component: () => import('pages/Login/LoginView.vue') component: () => import('pages/Login/LoginView.vue')
}, },
@ -62,7 +62,7 @@ const routes = [
{ {
name: 'Account', name: 'Account',
path: '/account/conf', path: '/account/conf',
component: () => import('pages/Account/AccountConf.vue') component: () => import('pages/Account/AccountConfig.vue')
}, },
{ {
name: 'AddressesList', name: 'AddressesList',

View File

@ -50,12 +50,13 @@ export const userStore = defineStore('user', {
async loadData() { async loadData() {
const userData = await jApi.getObject( const userData = await jApi.getObject(
'SELECT id, nickname FROM account.myUser' 'SELECT id, nickname, name FROM account.myUser'
); );
this.$patch({ this.$patch({
id: userData.id, id: userData.id,
nickname: userData.nickname nickname: userData.nickname,
name: userData.name
}); });
} }
} }