Simulando un Signup
Nuevamente, como al comienzo del post sobre el Login, una advertencia importante:
Lo que sigue a continuación no es un Signup real, es sólo un ejercicio para practicar la lógica de comunicación entre componentes en Vue, y una primera aproximación al tema de la interacción entre frontend y backend.
Al igual que con el Login, para hacer un Signup real habría que crear una aplicación de backend o usar algún Backend as a Service como Firebase, Supabase o AWS.
El componente de Signup
Si ya hicieron el componente de Login siguiendo el ejemplo que está acá y el componente del formulario siguiendo este otro ejemplo, hacer el componente de Signup no va a ser tan difícil porque lo principal ya lo tienen resuelto:
1. La navegación entre NavBar, Login y Signup, ya sea usando Vue Router, simulando un router, o con ventanas modales para los formularios de Login y de Signup.
2. La lógica del formulario y sus validaciones dentro de una store (que en el ejemplo del formulario llamamos formStore).
3. La store del usuario (que en el ejemplo del Login llamamos userStore) para que la lógica del usuario pueda ser accedida desde todos los componentes de la app que la necesiten .
4. El endpoint de MockApi donde guardar la información de los usuarios.
5. El servicio de acceso al backend en el archivo fetchService.js
(que vamos a tener que ampliar para hacer peticiones de tipo POST).
Y en cuanto al componente en sí, se va a parecer mucho al del ejercicio sobre el formulario, ya que utiliza los mismos campos. El único campo adicional sería el del username (UsernameInput
) que es muy parecido al del nombre (FirstNameInput
):
<template>
<div class="form">
<FirstNameInput/>
<LastNameInput/>
<EmailInput/>
<UsernameInput/>
<PasswordInput/>
<p>* Estos campos son obligatorios.</p>
<button @click="">REGISTRARME</button>
<p>
¿Ya tenés una cuenta? Ingresá
<a href="" @click.prevent="">aquí.</a>
</p>
</div>
</template>
Por ahora lo dejamos así, más adelante le vamos a agregar el método que se dispara al clickear en el botón.
Validación del nombre de usuario
En el ejemplo del formulario la validación de nombre y apellido chequeaba que el campo no esté vacío, que el nombre no sea ni demasiado corto ni demasiado largo, y que sólo contenga letras (incluyendo la ñ y las tildes). Y luego en el componente FirstNameInput
se mostraban los mensajes de error si no se cumplían estas validaciones:
<template>
<div>
<i><UserIcon/></i>
<input
type="text"
v-model="firstname"
placeholder="Nombre(s)"
/>
<span>*</span>
<ToolTip v-show="error" :error="error"/>
</div>
</template>
<script>
import UserIcon from '@/components/icons/UserIcon.vue'
import ToolTip from './ToolTip.vue'
import { formStore } from '@/stores/formStore'
export default {
components: {
UserIcon, ToolTip
},
data: () => ({
formStore,
}),
computed: {
error() {
return (
this.formStore.firstnameErrors().emptyField() ||
this.formStore.firstnameErrors().tooShort() ||
this.formStore.firstnameErrors().tooLong() ||
this.formStore.firstnameErrors().validChars()
)
},
firstname: {
get() {
return this.formStore.form.firstname
},
set(newValue) {
this.formStore.setNewValue('firstname', newValue)
}
}
}
}
</script>
El componente para username va a ser muy parecido pero la validación es un poco distinta, porque el nombre de usuario además de contener letras puede contener números y guiones (y, en general, no incluir caracteres como ñ o tildes).
Para eso vamos a tener que extender nuestra clase de validaciones para agregar esta nueva valicación. Y para chequear que el nombre de usuario sólo contenga letras, números o guiones usamos una Regular Expression:
// src/stores/validationClass.js
// Regular Expression: letras, números, guión medio o guión bajo
const usernameRegEx = /^[a-zA-Z0-9-_]+$/;
export class UsernameValidation extends Validation {
constructor (input, submitted) {
super(input, submitted)
}
invalidUsername() {
return (
this.input && !usernameRegEx.test(this.input)
) ? 'El nombre de usuario sólo puede contener letras, números o guiones.' : ''
}
noUsernameErrors() {
return (
!this.emptyField() &&
!this.tooShort() &&
!this.tooLong() &&
!this.invalidUsername()
)
}
}
Pero además de que el usuario ingrese un nombre de usuario válido tenemos que chequear que ese username no haya sido registrado previamente por otro usuario (o que el mismo usuario esté intentando volver a registrarse con el mismo username por error). Esto lo vamos a incluir en el método signupUser() en userStore.
También sería bueno chequear que no haya ningún usuario que se haya registrado previamente con el mismo email ya que si en algún momento queremos usar este email para, por ejemplo, reestablecer el password del usuario, necesitamos que cada usuario tenga un e-mail único.
Estos dos chequeos los tenemos que hacer dentro de nuestro método de Signup con un query a nuestro backend en MockApi.
Validaciones del backend
En el ejemplo del Login nuestra store del usuario había quedado así:
const { VITE_MOCKAPI_URL: baseUrl } = import.meta.env
import { hashSync, compareSync } from 'bcrypt-ts/browser'
import { fetchService as userService } from '@/services/fetchService'
import { formStore } from './formStore'
export const userStore = {
formStore,
user: null,
get loggedIn() {
return !!this.user
},
async loginUser() {
this.formStore.submitted = true
if (this.formStore.checkLogin()) {
const userQuery = `${baseUrl}/users?username=${this.formStore.form.username}`
const response = await userService.getData(userQuery)
const user = response[0]
const checkPassword = user && compareSync(
this.formStore.form.password, user.password
)
if (!user) {
this.formStore.serverErrors.username =
'Usuario no registrado.'
} else if (!checkPassword) {
this.formStore.serverErrors.password =
'Password inválido.'
} else {
this.user = user
this.formStore.resetForm()
}
}
},
logoutUser() {
this.user = null
}
}
Ahora le vamos a agregar un nuevo método para el Signup:
const { VITE_MOCKAPI_URL: baseUrl } = import.meta.env
import { hashSync, compareSync } from 'bcrypt-ts/browser'
import { fetchService as userService } from '@/services/fetchService'
import { formStore } from './formStore'
export const userStore = {
formStore,
user: null,
get loggedIn() {
return !!this.user
},
async loginUser() {
// Esto queda igual
},
async signupUser() {
this.formStore.submitted = true
if (this.formStore.checkSignup()) {
// Query para buscar un usuario con el nombre ingresado
const userQuery = `${baseUrl}/users?username=${this.formStore.form.username}`
const userResponse = await userService.getData(userQuery)
const username = userResponse[0]
// Query para buscar un usuario con el email ingresado
const emailQuery = `${baseUrl}/users?email=${this.formStore.form.email}`
const emailResponse = await userService.getData(emailQuery)
const email = emailResponse[0]
if (username) {
// Si ese nombre ya está registrado alertar con un mensaje:
this.formStore.serverErrors.takenUsername =
'Ya existe un usuario con ese nombre.'
} else if (email) {
// Si ese email ya está registrado alertar con un mensaje:
this.formStore.serverErrors.takenEmail =
'Ya existe un usuario con ese e-mail.'
}
}
}
}
Y ahora ya podemos agregar estos mensajes de error. Primero en el componente UsernameInput:
<template>
<div>
<i><UserIcon/></i>
<input
type="text"
v-model="username"
placeholder="user1"
@keyup="formStore.resetServerErrors()"
/>
<ToolTip v-show="error" :error="error"/>
</div>
</template>
<script>
import UserIcon from '@/components/icons/UserIcon.vue'
import ToolTip from './ToolTip.vue'
import { formStore } from '@/stores/formStore'
export default {
components: {
UserIcon, ToolTip
},
props: {
parentForm: String
},
data: () => ({
formStore,
}),
computed: {
error() {
if (this.parentForm === 'login') {
return (
this.formStore.usernameErrors().emptyField() ||
this.formStore.serverErrors.wrongUsername
)
} else if (this.parentForm === 'signup') {
return (
this.formStore.usernameErrors().emptyField() ||
this.formStore.usernameErrors().tooShort() ||
this.formStore.usernameErrors().tooLong() ||
this.formStore.usernameErrors().invalidUsername() ||
this.formStore.serverErrors.takenUsername
)
}
},
username: {
get() {
return this.formStore.form.username
},
set(newValue) {
this.formStore.setNewValue('username', newValue)
}
}
}
}
</script>
Este mismo componente lo vamos a usar tanto dentro del componente LoginForm como del componente SignupForm.
El problema es que cuando UsernameInput está dentro de LoginForm las validaciones son distintas a las que usa cuando está dentro de SignupForm, porque en el Login sólo hay que chequear que el campo no esté vacío y que el username sea correcto, o sea, que exista en el backend (this.formStore.serverErrors.wrongUsername
).
Pero en el Signup tenemos que chequear otras cosas: que no sea demasiado corto ni demasiado largo, que use los caracteres permitidos, y chequear con el backend que no sea un username repetido (this.formStore.serverErrors.takenUsername
).
Entonces, ¿cómo hacemos para que UsernameInput se comporte en forma distinta según esté dentro del Login o dentro del Signup? Una solución posible es pasándole una prop (parentForm: String
) desde el componente padre, que sea login
cuando el componente padre es Login y signup
cuando es Signup.
Puede que haya alguna otra solución mejor, por ejemplo, usando la propiedad nativa de Vue this.$parent
(que retorna el nombre del componente padre) pero creo que resulta más claro haciéndolo de esta otra forma.
Entonces, en la computed que retorna los mensajes de error ponemos un ìf
para chequear cuál es el componente padre, y que retorne los distintos mensajes de error según sea uno u otro:
error() {
if (this.parentForm === 'login') {
// Si es la página de Login que retorne los errores de Login
return (
this.formStore.usernameErrors().emptyField() ||
this.formStore.serverErrors.wrongUsername
)
} else if (this.parentForm === 'signup') {
// Si es la página de Signup que retorne los errores de Signup
return (
this.formStore.usernameErrors().emptyField() ||
this.formStore.usernameErrors().tooShort() ||
this.formStore.usernameErrors().tooLong() ||
this.formStore.usernameErrors().invalidUsername() ||
this.formStore.serverErrors.takenUsername
)
}
},
Fíjense que también hay que agregar el método formStore.resetServerErrors()
en el input. Esto es porque si el usuario ingresa un nombre que no está registrado se va a mostrar un mensaje de error, pero cuando intente corregirlo ese mensaje de error tiene que desaparecer. Para saber si el usuario está intentando corregirlo usamos el evento @keyup
:
<input
type="text"
v-model="username"
placeholder="user1"
@keyup="formStore.resetServerErrors()"
/>
Chequeo en el componente del password
Para el componente PasswordInput (que ya habíamos creado en el tutorial sobre el Login) la lógica va a ser similar:
<template>
<div>
<i><PasswordIcon/></i>
<input
:type="inputType"
class="input"
v-model="password"
@keyup="formStore.resetServerErrors()"
placeholder="test123"
/>
<VisibleIcon
:visible="visible"
@click.native="visible = !visible"
class="visible-icon"
/>
<ToolTip v-show="error" :error="error"/>
</div>
</template>
<script>
import PasswordIcon from '@/components/icons/PasswordIcon.vue'
import VisibleIcon from '@/components/icons/VisibleIcon.vue'
import ToolTip from './ToolTip.vue'
import { formStore } from '@/stores/formStore'
export default {
components: {
PasswordIcon, VisibleIcon, ToolTip
},
props: {
parentForm: String
},
data: () => ({
formStore,
visible: false
}),
computed: {
error() {
if (this.parentForm === 'login') {
// Si esta en la pagina de Login que retorne los errores de Login
return (
this.formStore.passwordErrors().emptyField() ||
this.formStore.serverErrors.wrongPassword
)
} else if (this.parentForm === 'signup') {
// Si esta en la pagina de Signup que retorne los errores de Signup
return (
this.formStore.passwordErrors().emptyField() ||
this.formStore.passwordErrors().tooLong() ||
this.formStore.passwordErrors().invalidPassword()
)
}
},
inputType() {
return this.visible ? 'text' : 'password'
},
password: {
get() {
return this.formStore.form.password
},
set(newValue) {
this.formStore.setNewValue('password', newValue)
}
}
}
}
</script>
Chequeo en el componente del email
En el tutorial sobre el formulario habíamos creado un componente para el input del email (EmailInput). Ahora le podemos agregar a este componente el mensaje de error en caso de que el usuario esté intentando registrarse con un e-mail que ya fue registrado (this.formStore.serverErrors.takenEmail
):
<template>
<div>
<i><EmailIcon/></i>
<input
type="text"
v-model="email"
placeholder="email@email.com"
@keyup="formStore.resetServerErrors()"
/>
<span>*</span>
<ToolTip v-show="error" :error="error"/>
</div>
</template>
<script>
import EmailIcon from '@/components/icons/EmailIcon.vue'
import ToolTip from './ToolTip.vue'
import { formStore } from '@/stores/formStore'
export default {
components: {
EmailIcon, ToolTip
},
data: () => ({
formStore
}),
computed: {
error() {
return (
this.formStore.emailErrors().emptyField() ||
this.formStore.emailErrors().tooLong() ||
this.formStore.emailErrors().invalidEmail() ||
this.formStore.serverErrors.takenEmail
)
},
email: {
get() {
return this.formStore.form.email
},
set(newValue) {
this.formStore.setNewValue('email', newValue)
}
}
}
}
</script>
Completar el método singupUser()
Volvamos a userStore y retomemos el método signupUser:
async signupUser() {
this.formStore.submitted = true
if (this.formStore.checkSignup()) {
// Query para buscar un usuario con el nombre ingresado
const userQuery = `${baseUrl}/users?username=${this.formStore.form.username}`
const userResponse = await userService.getData(userQuery)
const username = userResponse[0]
// Query para buscar un usuario con el email ingresado
const emailQuery = `${baseUrl}/users?email=${this.formStore.form.email}`
const emailResponse = await userService.getData(emailQuery)
const email = emailResponse[0]
if (username) {
// Si ese nombre ya está registrado alertar con un mensaje:
this.formStore.serverErrors.takenUsername =
'Ya existe un usuario con ese nombre.'
} else if (email) {
// Si ese email ya está registrado alertar con un mensaje:
this.formStore.serverErrors.takenEmail =
'Ya existe un usuario con ese e-mail.'
}
}
}
Este método nos está quedando demasiado largo... las queries de username y de email son muy repetitivas... Tratemos de abreviar un poco. Para eso podemos crear un método aparte para las queries que se llame, por ejemplo, findUser() y que tome como parámetro la query para buscar los datos del usuario en MockApi:
async findUser(query) {
// OJO: MockApi retorna siempre un Array, aunque encuentre un solo item
// Y si no encuentra nada retorna un Array vacio
const userQuery = `${baseUrl}/users?${query}`;
const res = await userService.getData(userQuery)
// Si el array está vacío retorna undefined
console.log({res})
// Retornar el primer item del Array
return res[0]
},
Y ahora podemos usar este método tanto en signupUser como en loginUser:
export const userStore = {
formStore,
user: null,
get loggedIn() {
return !!this.user
},
async findUser(query) {
const userQuery = `${baseUrl}/users?${query}`;
const res = await userService.getData(userQuery)
console.log({res})
return res[0]
},
async loginUser() {
this.formStore.submitted = true
if (this.formStore.checkLogin()) {
const query = `username=${this.formStore.form.username}`
const user = await this.findUser(query)
const checkPassword = user && compareSync(
this.formStore.form.password, user.password
)
if (!user) {
this.formStore.serverErrors.username =
'Usuario no registrado.'
} else if (!checkPassword) {
this.formStore.serverErrors.password =
'Password inválido.'
} else {
this.user = user
this.formStore.resetForm()
}
}
},
async signupUser() {
this.formStore.submitted = true
if (this.formStore.checkSignup()) {
const userQuery = `username=${this.formStore.form.username}`
const username = await this.findUser(userQuery)
const emailQuery = `email=${this.formStore.form.email}`
const email = await this.findUser(emailQuery)
if (username) {
this.formStore.serverErrors.takenUsername =
'Ya existe un usuario con ese nombre.'
} else if (email) {
this.formStore.serverErrors.takenEmail =
'Ya existe un usuario con ese e-mail.'
}
}
},
logoutUser() {
this.user = null
}
}
Así está mejor. Ahora, una vez que ya chequeamos que el usuario ingresó todos los datos de registro correctamente, tenemos que guardarlos en MockApi.
Usando un fetch
de tipo POST
Para obtener data de una REST API el único param que necesita el método fetch
es la URL del endpoint, tal como lo habíamos hecho para el método getData(url) en el archivo fetchService.js del tutorial sobre el Login:
// src/services/fetchService.js
export const fetchService = {
async getData(url) {
try {
const res = await (await fetch(url)).json()
return res
} catch (error) {
console.log(error)
}
}
}
Para el Login con esto era suficiente, pero ahora queremos guardar data en MockApi, no sólo obtenerla. Para eso tenemos que usar fetch
con el método POST (también se lo suele llamar verbo POST 🤷♂️️).
Además, tenemos que incluir headers para indicarle a la REST API que le estamos enviando información en formato JSON:
const headers = { 'content-type': 'application/json' }
Y para poder enviar los datos del usuario dentro de la propiedad body
primero tenemos que transformar el objeto de JavaScript que los contiene en un String que MockApi pueda recibir y guardar. Para eso hay que usar el método JSON.stringify
:
const { stringify } = JSON
Entonces, con estos tres datos (method
, headers
y body
) podemos armar el objeto options
que le vamos a pasar al método fetch
como segundo param luego de la URL:
const { stringify } = JSON
const headers = { 'content-type': 'application/json' }
const options = {
headers,
method: 'POST',
body: stringify(data)
}
Y ahora ya podemos agregar el nuevo método createData(url, data) para enviar la data al backend:
// src/services/fetchService.js
const { stringify } = JSON
const headers = {'content-type': 'application/json'}
export const fetchService = {
async getData(url) {
try {
const res = await (await fetch(url)).json()
return res
} catch (error) {
console.log(error)
}
},
async createData(url, data) {
const options = {
headers,
method: 'POST',
body: stringify(data)
}
try {
const res = await (await fetch(url, options)).json
return res
} catch (error) {
console.log (error)
}
}
}
Y ahora ya podemos agregar el nuevo método createData(url, data) dentro de signupUser en userStore:
async signupUser() {
this.formStore.submitted = true
if (this.formStore.checkSignup()) {
const userQuery = `username=${this.formStore.form.username}`
const username = await this.findUser(userQuery)
const emailQuery = `email=${this.formStore.form.email}`
const email = await this.findUser(emailQuery)
if (username) {
this.formStore.serverErrors.takenUsername =
'Ya existe un usuario con ese nombre.'
} else if (email) {
this.formStore.serverErrors.takenEmail =
'Ya existe un usuario con ese e-mail.'
} else {
// Si no hay errores, crear el nuevo usuario:
const endpoint = `${baseUrl}/users`
const data = {
...this.formStore.form,
hashSync(this.formStore.form.password, 10)
}
const res = await userService.createData(endpoint, data)
// Resetear los campos del formulario
this.formStore.resetForm()
// Retornar la respuesta del backend
console.log({res})
return res
}
}
},
En el tutorial del Login habíamos importado la librería bcrypt-ts para comparar el hash guardado en MockApi con la versión hasheada del password ingresado por el usuario, usando el método compareSync(plaintext, hash). Y ahora usamos el método hashSync(plaintext, rounds) para hashear el password antes de guardarlo:
const data = {
...this.formStore.form,
// Hashear el password antes de guardarlo
// 10 es la cantidad de rounds de hasheo
hashSync(this.formStore.form.password, 10)
}
Advertencia sobre el hashing
Recordando lo que decíamos en el tutorial del Login: esto es sólo un ejercicio de prueba, en una app real el hashing siempre se hace en el backend.
Esto es porque hashear los passwords en el frontend no tiene mucho sentido. Si un hacker intercepta la conexión y obtiene el hash, lo único que tiene que hacer es enviar ese hash como si fuese un password y como el hash va a coincidir con el que está guardado en el backend podría ingresar a nuestra aplicación usando la cuenta de ese usuario y robar sus datos 🤦♂️️
Y en realidad, esto también puede pasar si lo que enviamos al backend es el password en plaintext. Para mitigar este riesgo es que se inventó el protocolo HTTPS que encripta automáticamente todo tipo de comunicación entre frontend y backend. Para usar HTTPS nuestro frontend tiene que estar alojado en un servidor que utilice este protocolo. Si estamos usando servicios PaaS como Netlify, Vercel o GitHub Pages, esto es automático, no tenemos que configurar nada 👍️
Agregar el método signupUser en el componente
Y ahora ya podemos agregar el método para registrar al nuevo usuario en el componente SignupForm y, si todo salió bien, mostrarle al usuario un mensaje de agradecimiento ('Gracias por registrarte!'):
<template>
<div class="form">
<FirstNameInput/>
<LastNameInput/>
<EmailInput/>
<UsernameInput/>
<PasswordInput/>
<p>* Estos campos son obligatorios.</p>
<button @click="signupUser">REGISTRARME</button>
<p>
¿Ya tenés una cuenta? Ingresá
<a href="" @click.prevent="goToLogin">aquí.</a>
</p>
</div>
</template>
<script>
import EmailInput from './inputs/EmailInput.vue'
import UsernameInput from './inputs/UsernameInput.vue'
import FirstNameInput from './inputs/FirstNameInput.vue'
import LastNameInput from './inputs/LastNameInput.vue'
import PasswordInput from './inputs/PasswordInput.vue'
import { navStore } from '@/stores/navStore'
import { formStore } from '@/stores/formStore'
import { userStore } from '@/stores/userStore'
export default {
components: {
EmailInput,
UsernameInput,
FirstNameInput,
LastNameInput,
PasswordInput
},
data: () => ({
formStore,
userStore
}),
methods: {
async signupUser() {
const signedUp = await this.userStore.signupUser()
if (signedUp) {
this.$emit('show-signup-message')
// Y si estamos simulando un router:
// this.navStore.showPage('signupMessage')
// Y si estamos usando Vue Router:
// this.$router.push('/signupMessage')
}
},
goToLogin() {
this.formStore.resetForm()
this.$emit('show-login')
// Y si estamos simulando un router:
// this.navStore.showPage('login')
// Y si estamos usando Vue Router:
// this.$router.push('/login')
}
}
}
</script>
Luego, en el componente que muestra el mensaje de agradecimiento, ponemos un link para redirigir a Login y que el usuario recién registrado pueda loggearse por primera vez:
<template>
<div>
<h2>¡Gracias por registrarte!</h2>
<p>Ya podés <a href="" @click.prevent="goToLogin">ingresar.</a></p>
</div>
</template>
Recuerden que en una app real habría un paso previo al primer Login: el backend debería enviarle un mail al usuario para confirmar que el mail que ingresó es real. Y en ese mail incluir un link que lo lleve a nuestra página de Login. Pero MockApi no es tan sofisticado como para eso. Para hacer algo así tendríamos que armar nuestro propio backend o usar serverless functions.
Conclusión
Como dijimos al comienzo, todo esto que vimos es más que nada un ejercicio para practicar la lógica de comunicación entre componentes, las validaciones, el uso de stores y el uso de fetch, que les va a servir cuando tengan que hacer un Signup real.
En una app real el registro de usuarios siempre se hace configurando nuestro propio backend o usando un Backend as a Service como Firebase, Supabase o AWS. Esto lo vamos a ver en un próximo post dedicado a Vue + Supabase 📝️