diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts index 3fe0ce56b8a4e1b978e6631f0e52db08477c7e21..3b3337710c66b4a24afb1c82330a9681663072d4 100644 --- a/cypress/e2e/login.cy.ts +++ b/cypress/e2e/login.cy.ts @@ -1,8 +1,13 @@ -describe('Register and login', () => { +describe('Login', () => { beforeEach(() => { cy.visit('/login') }) + function fullInput() { + cy.get('input[name=username]').type('test') + cy.get('input[name=password]').type('test') + } + it('visits the login page as default', () => { cy.contains('button', 'Logg inn') }) @@ -22,13 +27,24 @@ describe('Register and login', () => { }) it('enables the login button when both username and password is input', () => { - cy.get('input[name=username]').type('test') - cy.get('input[name=password]').type('test') + fullInput() cy.contains('button', 'Logg inn').should('not.be.disabled') }) - it('visits the register page when clicked', () => { - cy.contains('h3', 'Registrer deg').click() - cy.contains('button', 'Registrer deg') + it('pushes the the user to root page on successful login', () => { + cy.intercept('POST', 'http://localhost:8080/auth/login', { + body: { + accessToken: 'fakeToken', + refreshToken: 'fakeToken' + } + }).as('login') + + fullInput() + + cy.get('button[name=submit]').click() + + cy.wait('@login') + + cy.url().should('include', '/') }) }) diff --git a/cypress/e2e/register.cy.ts b/cypress/e2e/register.cy.ts index 923797b11fddcdb13614e80d04891080a5a8e01e..a0c04d07ecf4d60e9ecc5934b1bbaeb12aa63f30 100644 --- a/cypress/e2e/register.cy.ts +++ b/cypress/e2e/register.cy.ts @@ -1,30 +1,45 @@ -describe('Register and login', () => { +describe('Register', () => { beforeEach(() => { cy.visit('/login') cy.contains('h3', 'Registrer deg').click() }) + function fullInput() { + cy.get('input[name="firstname"]').type('firstname') + cy.get('input[name="lastname"]').type('lastname') + cy.get('input[name="email"]').type('email@test.work') + cy.get('input[name="username"]').type('username') + cy.get('input[name="password"]').type('Password123!') + cy.get('input[name="confirm"]').type('Password123!') + } + it('visits the register page when clicked', () => { - cy.contains('button', 'Registrer deg') + cy.contains('button[name="submit"]', 'Registrer deg') }) it('disables the login button when no input', () => { - cy.contains('button', 'Registrer deg').should('be.disabled') + cy.get('button[name="submit"]').should('be.disabled') }) - it('disables the login button when only username is input', () => { - cy.get('input[name=username]').type('test') - cy.contains('button', 'Registrer deg').should('be.disabled') - }) + it('enable the login button when all inputs are filled and l', () => { + fullInput() - it('disables the login button when only password is input', () => { - cy.get('input[name=password]').type('test') - cy.contains('button', 'Registrer deg').should('be.disabled') + cy.get('button[name="submit"]').should('not.be.disabled') }) - it('enables the login button when both username and password is input', () => { - cy.get('input[name=username]').type('test') - cy.get('input[name=password]').type('test') - cy.contains('button', 'Registrer deg').should('not.be.disabled') + it('pushes the user to the root page on successful register', () => { + cy.intercept('POST', 'http://localhost:8080/auth/register', { + body: { + accessToken: 'fakeToken', + refreshToken: 'fakeToken' + } + }).as('register') + + fullInput() + + cy.get('button[name="submit"]').click() + + cy.wait('@register') + cy.url().should('include', '/') }) }) diff --git a/src/assets/coins.png b/src/assets/coins.png new file mode 100644 index 0000000000000000000000000000000000000000..88508f81eea8438785a0ac052d9112c375a80891 Binary files /dev/null and b/src/assets/coins.png differ diff --git a/src/components/ContinueButtonComponent.vue b/src/components/ContinueButtonComponent.vue index 6b42981605e2a775d79af5c241188a6a8b11b2b7..b3572a1a04fe77c1f0505f5ab7cab9bf53e61b16 100644 --- a/src/components/ContinueButtonComponent.vue +++ b/src/components/ContinueButtonComponent.vue @@ -7,9 +7,6 @@ import { defineComponent } from 'vue' export default defineComponent({ name: 'ContinueButtonComponent', - emits: { - click: (event: Event) => true - }, setup(props, { emit }) { const onClick = (event: Event) => { emit('click', event) diff --git a/src/components/FormLogin.vue b/src/components/FormLogin.vue index 243a79a1caccd230d0fb33f963b523d05fcdc496..ebdac31421a23a0fcccc7573bbc596a9ba7df078 100644 --- a/src/components/FormLogin.vue +++ b/src/components/FormLogin.vue @@ -26,7 +26,7 @@ watch( </script> <template> - <div class="flex flex-col justify-center gap-5 mx-10"> + <div class="flex flex-col justify-center gap-5 w-full"> <div class="flex flex-col"> <p class="mx-4">Brukernavn</p> <input @@ -53,6 +53,7 @@ watch( </div> <div class="flex flex-row gap-5"> <button + name="submit" :disabled="'' == username.valueOf() || '' == password.valueOf()" class="grow-0" @click="submitForm" diff --git a/src/components/FormRegister.vue b/src/components/FormRegister.vue index 1a298fdde4bca1149c02591ea0d186e84bb24477..4a94b4eedb0db873bc5e43672d969c1c7f512582 100644 --- a/src/components/FormRegister.vue +++ b/src/components/FormRegister.vue @@ -1,16 +1,40 @@ <script lang="ts" setup> -import { ref, watch } from 'vue' +import { computed, ref, watch } from 'vue' import { useUserStore } from '@/stores/userStore' +import ToolTip from '@/components/ToolTip.vue' +const firstname = ref<string>('') +const lastname = ref<string>('') +const email = ref<string>('') const username = ref<string>('') const password = ref<string>('') +const confirm = ref<string>('') + const showPassword = ref<boolean>(false) const errorMessage = ref<string>('') const userStore = useUserStore() +const nameRegex = /^[a-zA-Z ,.'-]+$/ +const emailRegex = /^[a-zA-Z0-9_+&*-]+(?:\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,7}$/ +const usernameRegex = /^[A-Za-z][A-Za-z0-9_]{2,29}$/ +const passwordRegex = /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=!])(?=\S+$).{8,}$/ + +const isFirstNameValid = computed(() => nameRegex.test(firstname.value) && firstname.value) +const isLastNameValid = computed(() => nameRegex.test(lastname.value) && lastname.value) +const isEmailValid = computed(() => emailRegex.test(email.value)) +const isUsernameValid = computed(() => usernameRegex.test(username.value)) +const isPasswordValid = computed(() => passwordRegex.test(password.value)) + +const isFormInvalid = computed( + () => + [isFirstNameValid, isLastNameValid, isEmailValid, isUsernameValid, isPasswordValid].some( + (v) => !v.value + ) || password.value !== confirm.value +) + const submitForm = () => { - userStore.register(username.value, password.value) + userStore.register(firstname.value, lastname.value, email.value, username.value, password.value) } const toggleShowPassword = () => { @@ -26,40 +50,103 @@ watch( </script> <template> - <div class="flex flex-col justify-center gap-5 mx-10"> + <div class="flex flex-col justify-center gap-5 w-full"> + <div class="flex flex-col"> + <div class="flex flex-row justify-between mx-4"> + <p>Fornavn*</p> + <ToolTip + :message="'Must include only letters, spaces, commas, apostrophes, periods, and hyphens.'" + /> + </div> + <input + v-model="firstname" + name="firstname" + :class="{ 'bg-green-200': isFirstNameValid }" + placeholder="Skriv inn fornavn" + type="text" + /> + </div> <div class="flex flex-col"> - <p class="mx-4">Brukernavn</p> + <div class="flex flex-row justify-between mx-4"> + <p>Etternavn*</p> + <ToolTip + :message="'Must include only letters, spaces, commas, apostrophes, periods, and hyphens.'" + /> + </div> + <input + v-model="lastname" + name="lastname" + :class="{ 'bg-green-200': isLastNameValid }" + placeholder="Skriv inn etternavn" + type="text" + /> + </div> + <div class="flex flex-col"> + <div class="flex flex-row justify-between mx-4"> + <p>E-post*</p> + <ToolTip + :message="'Must include a valid format with \'@\' and a domain, only letters, numbers, and special characters (_ + & * -) allowed.'" + /> + </div> + <input + v-model="email" + name="email" + :class="{ 'bg-green-200': isEmailValid }" + placeholder="Skriv inn e-post" + type="text" + /> + </div> + <div class="flex flex-col"> + <div class="flex flex-row justify-between mx-4"> + <p>Brukernavn*</p> + <ToolTip + :message="'Must start with a letter and can include numbers and underscores, 3-30 characters long.'" + /> + </div> <input v-model="username" name="username" placeholder="Skriv inn brukernavn" type="text" + :class="{ 'bg-green-200': isUsernameValid }" /> </div> <div class="flex flex-col"> - <p class="mx-4">Passord</p> + <div class="flex flex-row justify-between mx-4"> + <p>Passord*</p> + <ToolTip + :message="'Must be at least 8 characters, including at least one number, one lowercase letter, one uppercase letter, one special character (@#$%^&+=!), and no spaces.'" + /> + </div> <div class="relative"> <input name="password" v-model="password" :type="showPassword ? 'text' : 'password'" - class="w-full" placeholder="Skriv inn passord" + class="w-full" + :class="{ 'bg-green-200': isPasswordValid }" /> <button class="absolute right-0 top-1 bg-transparent" @click="toggleShowPassword"> {{ showPassword ? '🔓' : '🔒' }} </button> </div> + <input + v-model="confirm" + :class="{ 'bg-green-200': password == confirm && '' !== confirm.valueOf() }" + class="mt-2" + name="confirm" + placeholder="Bekreft passord" + type="password" + /> </div> <div class="flex flex-row gap-5"> - <button - :disabled="'' == username.valueOf() || '' == password.valueOf()" - class="grow-0" - @click="submitForm" - > + <button :disabled="isFormInvalid" class="grow-0" name="submit" @click="submitForm"> Registrer deg </button> <p>{{ errorMessage }}</p> </div> </div> </template> + +<style scoped></style> diff --git a/src/components/FormUserDetails.vue b/src/components/FormUserDetails.vue deleted file mode 100644 index b70ab1a5bc31acbde19fabe9ab1114a76535850a..0000000000000000000000000000000000000000 --- a/src/components/FormUserDetails.vue +++ /dev/null @@ -1,7 +0,0 @@ -<script lang="ts" setup></script> - -<template> - <h3>Logg inn</h3> -</template> - -<style scoped></style> diff --git a/src/components/ToolTip.vue b/src/components/ToolTip.vue new file mode 100644 index 0000000000000000000000000000000000000000..ab2e58b9cbd31702490c7612e121f2252c82446f --- /dev/null +++ b/src/components/ToolTip.vue @@ -0,0 +1,36 @@ +<script lang="ts" setup> +import { ref } from 'vue' + +defineProps({ + message: String +}) + +const show = ref(false) + +const toggleShow = () => { + show.value = !show.value +} +</script> + +<template> + <div class="relative"> + <div + class="cursor-pointer" + tabindex="0" + @mouseleave="show = false" + @mouseover="show = true" + @keydown.space.prevent="toggleShow" + @keydown.enter.prevent="toggleShow" + > + (?) + </div> + <div + v-if="show" + class="absolute -inset-x-36 z-10 p-2 mt-2 w-40 text-sm bg-gray-100 rounded shadow-lg" + > + {{ message }} + </div> + </div> +</template> + +<style scoped></style> diff --git a/src/components/__tests__/FormRegisterTest.spec.ts b/src/components/__tests__/FormRegisterTest.spec.ts index 561209727bf8544ddeb2b9ca585a9c99c7bfbf6d..935801335b6ca43cd26082a3ea8a25d07a33a833 100644 --- a/src/components/__tests__/FormRegisterTest.spec.ts +++ b/src/components/__tests__/FormRegisterTest.spec.ts @@ -23,49 +23,71 @@ describe('FormRegister', () => { mock.restore() }) + function successfulFormFill() { + wrapper.find('input[name="firstname"]').setValue('firstname') + wrapper.find('input[name="lastname"]').setValue('lastname') + wrapper.find('input[name="email"]').setValue('email@test.work') + wrapper.find('input[name="username"]').setValue('username') + wrapper.find('input[name="password"]').setValue('Password123!') + wrapper.find('input[name="confirm"]').setValue('Password123!') + } + it('renders properly', () => { expect(wrapper.text()).toContain('Brukernavn') expect(wrapper.text()).toContain('Passord') expect(wrapper.text()).toContain('Registrer deg') - expect(wrapper.find('input[type="text"]').exists()).toBe(true) - expect(wrapper.find('input[type="password"]').exists()).toBe(true) - expect(wrapper.find('button').exists()).toBe(true) + expect(wrapper.find('input[name="firstname"]').exists()).toBe(true) + expect(wrapper.find('input[name="lastname"]').exists()).toBe(true) + expect(wrapper.find('input[name="email"]').exists()).toBe(true) + expect(wrapper.find('input[name="username"]').exists()).toBe(true) + expect(wrapper.find('input[name="password"]').exists()).toBe(true) + expect(wrapper.find('input[name="confirm"]').exists()).toBe(true) + + expect((wrapper.find('input[name="firstname"]').element as HTMLInputElement).value).toBe('') + expect((wrapper.find('input[name="lastname"]').element as HTMLInputElement).value).toBe('') + expect((wrapper.find('input[name="email"]').element as HTMLInputElement).value).toBe('') + expect((wrapper.find('input[name="username"]').element as HTMLInputElement).value).toBe('') + expect((wrapper.find('input[name="password"]').element as HTMLInputElement).value).toBe('') + expect((wrapper.find('input[name="confirm"]').element as HTMLInputElement).value).toBe('') - expect((wrapper.find('input[type="text"]').element as HTMLInputElement).value).toBe('') - expect((wrapper.find('input[type="password"]').element as HTMLInputElement).value).toBe('') + expect(wrapper.find('button[name="submit"]').exists()).toBe(true) }) it('disables button when none inputs are filled', () => { - const button = wrapper.findAll('button').find((b: any) => b.text() === 'Registrer deg') + const button = wrapper.find('button[name="submit"]') expect(button.attributes('disabled')).toBeDefined() }) - it('disables button when only username is filled', () => { - const button = wrapper.findAll('button').find((b: any) => b.text() === 'Registrer deg') + it('enables button when all inputs are filled', async () => { + const button = wrapper.find('button[name="submit"]') - const inputUsername = wrapper.find('input[type="text"]') - inputUsername.setValue('username') - expect(button.attributes('disabled')).toBeDefined() + successfulFormFill() + + await wrapper.vm.$nextTick() + + expect(button.attributes('disabled')).toBeUndefined() }) - it('disables button when only password is filled', () => { - const button = wrapper.findAll('button').find((b: any) => b.text() === 'Registrer deg') + it('disables button when password and confirm password do not match', async () => { + const button = wrapper.find('button[name="submit"]') + + successfulFormFill() + wrapper.find('input[name="confirm"]').setValue('Password123') + + await wrapper.vm.$nextTick() - const inputPassword = wrapper.find('input[type="password"]') - inputPassword.setValue('password') expect(button.attributes('disabled')).toBeDefined() }) - it('enables button when input', async () => { - const button = wrapper.findAll('button').find((b: any) => b.text() === 'Registrer deg') - const inputUsername = wrapper.find('input[type="text"]') - const inputPassword = wrapper.find('input[type="password"]') + it('disable button when email is invalid', async () => { + const button = wrapper.find('button[name="submit"]') + + successfulFormFill() + wrapper.find('input[name="email"]').setValue('email') - inputUsername.setValue('username') - inputPassword.setValue('password') await wrapper.vm.$nextTick() - expect(button.attributes('disabled')).toBeUndefined() + expect(button.attributes('disabled')).toBeDefined() }) }) diff --git a/src/router/index.ts b/src/router/index.ts index ab1cad67c52d86e9cb5c98f930577ab8e5c0f260..17329b5f0f6a5625dc354e1f3952760e6cdb0b18 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -60,6 +60,11 @@ const router = createRouter({ path: '/konfigurasjonSteg4', name: 'configurations4', component: () => import('@/views/ConfigSpendingItemsAmountView.vue') + }, + { + path: '/forsteSparemaal', + name: 'firstSavingGoal', + component: () => import('@/views/FirstSavingGoalView.vue') } ] }) diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts index a84003491718e3c0427eb3ff9ee5500dab4d8071..f9a90d971d40dfa3cbafc91d8d5d3fa3622f1a4b 100644 --- a/src/stores/userStore.ts +++ b/src/stores/userStore.ts @@ -6,22 +6,37 @@ import router from '@/router' export const useUserStore = defineStore('user', () => { const defaultUser: User = { + firstname: 'Firstname', + lastname: 'Lastname', username: 'Username' } const user = ref<User>(defaultUser) const errorMessage = ref<string>('') - async function register(username: string, password: string) { + async function register( + firstname: string, + lastname: string, + email: string, + username: string, + password: string + ) { try { const response = await axios.post(`http://localhost:8080/auth/register`, { + firstName: firstname, //TODO rename all instances of firstname to firstName + lastName: lastname, + email: email, username: username, password: password }) sessionStorage.setItem('accessToken', response.data.accessToken) localStorage.setItem('refreshToken', response.data.refreshToken) + + user.value.firstname = firstname + user.value.lastname = lastname user.value.username = username + await router.push('/') } catch (error) { const axiosError = error as AxiosError diff --git a/src/types/user.ts b/src/types/user.ts index 1b2f93f0458374a1c1b04fd81f212ca964b55da8..a420d33a4deb8de0e12fb303edf205b3be5e703f 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -1,3 +1,5 @@ export interface User { + firstname: string + lastname: string username: string } diff --git a/src/views/FirstSavingGoalView.vue b/src/views/FirstSavingGoalView.vue new file mode 100644 index 0000000000000000000000000000000000000000..079e5d7b4efa9397fcad4f85220eba23b604eb00 --- /dev/null +++ b/src/views/FirstSavingGoalView.vue @@ -0,0 +1,84 @@ +<script setup lang="ts"> +import { ref, watch } from 'vue' +import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' +import router from '@/router' + +const savingsGoal = ref('') +const rawAmount = ref('') + +const validateAmount = () => { + const validPattern = /^(\d+)?(,\d*)?$/ + if (!validPattern.test(rawAmount.value)) { + rawAmount.value = rawAmount.value.slice(0, -1) + } else if (rawAmount.value.includes(',')) { + rawAmount.value = rawAmount.value.replace(/,+/g, ',') + } +} + +const checkNegative = () => { + const numericValue = parseFloat(rawAmount.value.replace(',', '.')) + if (numericValue < 0) { + rawAmount.value = '' + } +} + +watch(rawAmount, validateAmount) +watch(() => parseFloat(rawAmount.value.replace(',', '.')), checkNegative) + +const onButtonClick = () => { + router.push('/home') +} +</script> + +<template> + <div class="flex flex-col items-center justify-center min-h-screen px-4 text-center"> + <div class="mb-20"> + <div + class="flex flex-col items-center justify-center bg-white shadow-md rounded-lg p-16" + style="width: 400px; height: 400px" + > + <div class="mb-6 w-full text-left"> + <label for="savings-goal" class="block text-xl font-bold mb-2" + >Jeg vil spare til:</label + > + <input + type="text" + id="savings-goal" + v-model="savingsGoal" + :class="{ + 'border-[var(--green)]': savingsGoal.valueOf(), + 'border-gray-300': !savingsGoal.valueOf() + }" + class="border-2 block w-full rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 text-xl" + placeholder="" + /> + </div> + <div class="mb-8 w-full flex items-center"> + <label for="amount" class="shrink-0 text-xl font-bold mr-2" + >Jeg vil spare:</label + > + <input + type="text" + id="amount" + v-model="rawAmount" + :class="{ + 'border-[var(--green)]': rawAmount.valueOf(), + 'border-gray-300': !rawAmount.valueOf() + }" + class="border-2 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 text-xl mr-2 block w-full" + placeholder="" + min="0" + /> + <span class="shrink-0 text-xl font-bold">kr</span> + </div> + <div class="w-full px-4 py-2"> + <img src="@/assets/coins.png" alt="Savings" class="mx-auto w-36 h-32" /> + </div> + </div> + </div> + <ContinueButtonComponent + @click="onButtonClick" + class="px-10 py-3 text-2xl font-bold self-end" + ></ContinueButtonComponent> + </div> +</template> diff --git a/src/views/RegisterLoginView.vue b/src/views/RegisterLoginView.vue index e63cf8197bbe699db405040e962b165a6a0648c0..d664d4704fb812849ab1872ab0f624fbd4376a5b 100644 --- a/src/views/RegisterLoginView.vue +++ b/src/views/RegisterLoginView.vue @@ -7,15 +7,30 @@ const isLogin = ref<boolean>(true) </script> <template> - <div class="grid grid-cols-2 gap-10"> + <div class="flex flex-col items-center gap-5 justify-center sm:flex-row"> <div class="border-2 border-black flex items-center"> <h1>Dette er et bilde</h1> - <button class="border border-black">Test</button> </div> <div class="flex flex-col"> <div class="flex flex-row gap-5 justify-center"> - <h3 :class="{ selected: isLogin }" @click="isLogin = true">Logg inn</h3> - <h3 :class="{ selected: !isLogin }" @click="isLogin = false">Registrer deg</h3> + <h3 + :class="{ selected: isLogin }" + class="cursor-pointer" + tabindex="0" + @click="isLogin = true" + @keydown.enter.prevent="isLogin = true" + > + Logg inn + </h3> + <h3 + :class="{ selected: !isLogin }" + class="cursor-pointer" + tabindex="0" + @click="isLogin = false" + @keydown.enter.prevent="isLogin = false" + > + Registrer deg + </h3> </div> <FormLogin v-if="isLogin" /> <FormRegister v-else />