diff --git a/src/App.vue b/src/App.vue index be78194d361500e3ee20a20eb0d46747b92c4a2a..6cd1e8f97ea32c80831eb29c2ff51bc86c2722ac 100644 --- a/src/App.vue +++ b/src/App.vue @@ -10,7 +10,8 @@ const showNavBar = computed(() => { route.path == '/' || route.path == '/registrer' || route.path == '/logginn' || - route.path == '/forgotPassword' + route.path == '/forgotPassword' || + route.path.startsWith('/konfigurasjon') ) }) </script> diff --git a/src/assets/bioAuthFace.png b/src/assets/bioAuthFace.png new file mode 100644 index 0000000000000000000000000000000000000000..b03bb5d400029bcbc6d7bea9f5657da31e3e63a0 Binary files /dev/null and b/src/assets/bioAuthFace.png differ diff --git a/src/assets/bioAuthTouch.png b/src/assets/bioAuthTouch.png new file mode 100644 index 0000000000000000000000000000000000000000..363627b9c744b2a8dcabc9d2a69485d055045f43 Binary files /dev/null and b/src/assets/bioAuthTouch.png differ diff --git a/src/components/FormLogin.vue b/src/components/FormLogin.vue index 731e224e722324e37fb36ffd35f3bfe4ee2344f7..363580072249635f9a74c6a5e1e9ec0eac6cbdc3 100644 --- a/src/components/FormLogin.vue +++ b/src/components/FormLogin.vue @@ -15,11 +15,18 @@ const emailRegex = /^[a-zA-Z0-9_+&*-]+(?:\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\ const userStore = useUserStore() const isEmailValid = computed(() => emailRegex.test(resetEmail.value)) +const isSendingEmail = ref(false) +const successMessage = ref<string>('') +const modalErrorMessage = ref<string>('') const submitForm = () => { userStore.login(username.value, password.value) } +const bioLogin = () => { + userStore.bioLogin(username.value) +} + const toggleShowPassword = () => { showPassword.value = !showPassword.value } @@ -30,17 +37,36 @@ const openForgotPasswordModal = (event: MouseEvent) => { } const submitReset = async () => { - await axios.post('http://localhost:8080/forgotPassword/changePasswordRequest', { - email: resetEmail.value - }) - - resetEmail.value = '' - isModalOpen.value = false + isSendingEmail.value = true + modalErrorMessage.value = '' + successMessage.value = '' + try { + const response = await axios.post( + 'http://localhost:8080/forgotPassword/changePasswordRequest', + { + email: resetEmail.value + } + ) + successMessage.value = + 'E-posten er sendt. Vennligst sjekk innboksen din for instrukser. OBS: E-posten kan havne i spam-mappen' + isSendingEmail.value = false + setTimeout(() => { + isModalOpen.value = false + successMessage.value = '' + }, 5000) + } catch (error) { + console.error(error) + modalErrorMessage.value = 'Noe gikk galt. Vennligst prøv igjen.' + isSendingEmail.value = false + } } const closeModal = () => { - resetEmail.value = '' isModalOpen.value = false + isSendingEmail.value = false + modalErrorMessage.value = '' + resetEmail.value = '' + successMessage.value = '' } watch( @@ -95,36 +121,58 @@ watch( Logg inn </button> <p>{{ errorMessage }}</p> + <button @click="bioLogin">biologin</button> </div> </div> <modal-component :title="'Glemt passord'" :message="'Vennligst skriv inn e-posten din for Ã¥ endre passordet.'" :is-modal-open="isModalOpen" - @close="isModalOpen = false" + @close="closeModal" > <template v-slot:input> - <input - type="email" - v-model="resetEmail" - class="border border-gray-300 p-2 w-full mb-7" - placeholder="Skriv e-postadressen din her" - /> - </template> - <template v-slot:buttons> - <button - :disabled="!isEmailValid" - @click="submitReset" - class="active-button font-bold py-2 px-4 w-1/2 border-2 disabled:border-transparent" - > - Send mail - </button> - <button - @click="closeModal" - class="active-button font-bold py-2 px-4 w-1/2 border-2 disabled:border-transparent" - > - Lukk - </button> + <div v-if="isSendingEmail" class="flex justify-center items-center"> + <div + class="p-3 animate-spin drop-shadow-2xl bg-gradient-to-r from-lime-500 from-30% to-green-600 to-90% md:w-18 md:h-20 h-20 w-20 aspect-square rounded-full" + > + <div class="rounded-full h-full w-full bg-slate-100 background-blur-md"></div> + </div> + </div> + <div v-else-if="successMessage"> + <p class="text-green-500 text-center">{{ successMessage }}</p> + </div> + <div v-else-if="modalErrorMessage"> + <p class="text-red-500 text-center">{{ modalErrorMessage }}</p> + <button + @click="closeModal" + class="active-button font-bold py-2 px-4 w-1/2 mt-4 border-2 disabled:border-transparent" + > + Lukk + </button> + </div> + <div v-else> + <input + type="email" + v-model="resetEmail" + class="border border-gray-300 p-2 w-full mb-7" + placeholder="Skriv e-postadressen din her" + /> + <div class="flex gap-5 mt-4"> + <button + :disabled="!isEmailValid" + @click="submitReset" + class="active-button font-bold py-2 px-4 w-1/2 border-2 disabled:border-transparent" + > + Send mail + </button> + <button + @click="closeModal" + class="active-button font-bold py-2 px-4 w-1/2 border-2 disabled:border-transparent" + > + Lukk + </button> + </div> + </div> </template> </modal-component> </template> diff --git a/src/components/ImgGifTemplate.vue b/src/components/ImgGifTemplate.vue new file mode 100644 index 0000000000000000000000000000000000000000..c4d06b434a0f6a87f09a6d84a6b63287a0ef5840 --- /dev/null +++ b/src/components/ImgGifTemplate.vue @@ -0,0 +1,18 @@ +<template> + <img + v-if="index%6===modValue" + :src="url" + alt="could not load" + class="h-32 w-32 border-2 rounded-lg border-stale-400" + /> +</template> + +<script setup lang="ts"> +interface Props { + url: string + index: number + modValue: number +} + +const props = defineProps<Props>() +</script> diff --git a/src/components/SavingsPath.vue b/src/components/SavingsPath.vue index 5fc549f0745352ab19a091228d6b5c8b5703c2a9..f23676f1827c6d148efcd76194c30b0598e6c091 100644 --- a/src/components/SavingsPath.vue +++ b/src/components/SavingsPath.vue @@ -31,111 +31,84 @@ class="flex flex-row w-4/5 gap-8" > <div class="right-auto just"> - <img - v-if="index%6 ===3" - src="@/assets/sleepingSpare.gif" - alt="could not load" - class="w-32 h-32 border-2 rounded-lg border-stale-400" - /> - <img - v-else-if="index%6 === 1" - src="@/assets/golfSpare.gif" - alt="could not load" - class="w-32 h-32 border-2 rounded-lg border-stale-400" - /> - <img - v-else-if="index%6===5" - src="@/assets/archerSpare.gif" - alt="could not load" - class="w-32 h-32 border-2 rounded-lg border-stale-400" - /> + <img-gif-template :index="index" :mod-value="1" url="src/assets/golfSpare.gif"></img-gif-template> + <img-gif-template :index="index" :mod-value="3" url="src/assets/sleepingSpare.gif"></img-gif-template> + <img-gif-template :index="index" :mod-value="5" url="src/assets/archerSpare.gif"></img-gif-template> </div> <!-- Challenge Icon and Details --> - <div class="flex"> - <!-- Challenge Icon --> - <div class="flex flex-col items-center"> - <div class="flex flex-row flex-nowrap"> - <p class="text-center text-wrap text-xs md:text-lg" data-cy="challenge-title"> - {{ challenge.title }} - </p> - <display-info-for-challenge-or-goal :goal="goal" :challenge="challenge" :is-challenge="true"></display-info-for-challenge-or-goal> - </div> - <img - @click="editChallenge(challenge)" - :data-cy="'challenge-icon-' + challenge.id" - :src="getChallengeIcon(challenge)" - class="max-w-20 max-h-20 cursor-pointer" - :alt="challenge.title" - /> - <!-- Progress Bar, if the challenge is not complete --> - <div - v-if=" - challenge.completion != undefined && challenge.completion < 100 - " - class="flex-grow w-full mt-2" - > - <div class="flex flex-row ml-5 md:ml-10 justify-center"> - <div class="flex flex-col"> - <div - class="bg-gray-200 rounded-full h-2.5 dark:bg-gray-700" - > - <div - class="bg-green-600 h-2.5 rounded-full" - data-cy="challenge-progress" - :style="{ - width: - (challenge.saved / challenge.target) * 100 + - '%' - }" - ></div> - </div> - <div class="text-center text-xs md:text-base"> - {{ challenge.saved }}kr / {{ challenge.target }}kr - </div> - </div> - - <button - @click="incrementSaved(challenge)" - :data-cy="'increment-challenge' + challenge.id" - type="button" - class="inline-block mb-2 ml-2 h-7 w-8 rounded-full p-1 uppercase leading-normal transition duration-150 ease-in-out focus:bg-green-accent-300 focus:shadow-green-2 focus:outline-none focus:ring-0 active:bg-green-600 active:shadow-green-200 motion-reduce:transition-none dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong" - > - + - </button> - </div> - </div> - <span v-else class="text-center text-xs md:text-base">Ferdig: {{ challenge.saved }}</span> - </div> - <!-- Check Icon --> - <div - v-if="challenge.completion !== undefined && challenge.completion >= 100" - class="md:max-w-10 min-w-4 max-w-6 max-h-6 w-full h-auto md:max-h-10 min-h-4" - > - <img src="@/assets/completed.png" alt="" />ï¸ - </div> - <div v-else class="max-w-6 max-h-6"> - <img src="@/assets/pending.png" alt="" />ï¸ - </div> - </div> + <card-template> + <div class="flex"> + <!-- Challenge Icon --> + <div class="flex flex-col items-center gap-4"> + <div class="flex flex-row flex-nowrap"> + <p class="text-center text-wrap text-xs md:text-lg" data-cy="challenge-title"> + {{ challenge.title }} + </p> + <display-info-for-challenge-or-goal :goal="goal" :challenge="challenge" :is-challenge="true"></display-info-for-challenge-or-goal> + </div> + <img + @click="editChallenge(challenge)" + :data-cy="'challenge-icon-' + challenge.id" + :src="getChallengeIcon(challenge)" + class="max-w-20 max-h-20 cursor-pointer" + :alt="challenge.title" + /> + <!-- Progress Bar, if the challenge is not complete --> + <div + v-if=" + challenge.completion != undefined && challenge.completion < 100 + " + class="flex-grow w-full mt-2" + > + <div class="flex flex-row ml-5 md:ml-10 justify-center"> + <div class="flex flex-col"> + <div + class="bg-gray-200 rounded-full h-2.5 dark:bg-gray-700" + > + <div + class="bg-green-600 h-2.5 rounded-full" + data-cy="challenge-progress" + :style="{ + width: + (challenge.saved / challenge.target) * 100 + + '%' + }" + ></div> + </div> + <div class="text-center text-xs md:text-base"> + {{ challenge.saved }}kr / {{ challenge.target }}kr + </div> + </div> + + <button + @click="incrementSaved(challenge)" + :data-cy="'increment-challenge' + challenge.id" + type="button" + class="inline-block mb-2 ml-2 h-7 w-8 rounded-full p-1 uppercase leading-normal transition duration-150 ease-in-out focus:bg-green-accent-300 focus:shadow-green-2 focus:outline-none focus:ring-0 active:bg-green-600 active:shadow-green-200 motion-reduce:transition-none dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong" + > + + + </button> + </div> + </div> + <span v-else class="text-center text-xs md:text-base">Ferdig: {{ challenge.saved }}</span> + </div> + <!-- Check Icon --> + <div + v-if="challenge.completion !== undefined && challenge.completion >= 100" + class="md:max-w-10 min-w-4 max-w-6 max-h-6 w-full h-auto md:max-h-10 min-h-4" + > + <img src="@/assets/completed.png" alt="" />ï¸ + </div> + <div v-else class="max-w-6 max-h-6"> + <img src="@/assets/pending.png" alt="" />ï¸ + </div> + </div> + </card-template> <div class=""> - <img - v-if="index%6===0" - src="@/assets/cowboySpare.gif" - alt="could not load" - class="h-32 w-32 border-2 rounded-lg border-stale-400" - /> - <img - v-else-if="index%6 === 2" - src="@/assets/hotAirBalloonSpare.gif" - class="h-32 w-32 border-stale-400 border-2 rounded-lg" - alt="could not load" - /> - <img - v-else-if="index %6===4" - src="@/assets/farmerSpare.gif" - alt="could not load" - class="h-32 w-32 border-stale-400 border-2 rounded-lg" - /> + <img-gif-template :index="index" :mod-value="0" url="src/assets/cowboySpare.gif"></img-gif-template> + <img-gif-template :index="index" :mod-value="2" url="src/assets/hotAirBalloonSpare.gif"></img-gif-template> + <img-gif-template :index="index" :mod-value="4" url="src/assets/farmerSpare.gif"></img-gif-template> + </div> </div> <!-- Piggy Steps, centered --> @@ -209,6 +182,8 @@ import { useRouter } from 'vue-router' import { useGoalStore } from '@/stores/goalStore' import { useChallengeStore } from '@/stores/challengeStore' import DisplayInfoForChallengeOrGoal from "@/components/DisplayInfoForChallengeOrGoal.vue"; +import CardTemplate from "@/views/CardTemplate.vue"; +import ImgGifTemplate from "@/components/ImgGifTemplate.vue"; const router = useRouter() const goalStore = useGoalStore() diff --git a/src/components/__tests__/NavBarTest.spec.ts b/src/components/__tests__/NavBarTest.spec.ts index b31870d5340b4f2fe9642c8b74116ced1360c96f..ea7c2814a609abe8a0e43501fa558e7f45f70dc5 100644 --- a/src/components/__tests__/NavBarTest.spec.ts +++ b/src/components/__tests__/NavBarTest.spec.ts @@ -2,7 +2,7 @@ import { mount, VueWrapper } from '@vue/test-utils' import NavBar from '@/components/NavBarComponent.vue' import router from '@/router' import { createPinia, setActivePinia } from 'pinia' -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' vi.stubGlobal('scrollTo', vi.fn()) diff --git a/src/components/__tests__/savingsPathTest.spec.ts b/src/components/__tests__/savingsPathTest.spec.ts index af21d03c1b80df3ae2e90e8b92bd8e1782df544f..dea8650e2ad5d6ced6b43bfd45ce7bc012319875 100644 --- a/src/components/__tests__/savingsPathTest.spec.ts +++ b/src/components/__tests__/savingsPathTest.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import SavingsPath from '@/components/SavingsPath.vue' diff --git a/src/router/index.ts b/src/router/index.ts index 4ae0e9a4990bd24573cd01d10768f46825ef4c14..5f32e18fa4bcb61cf7ad48446e11dea346712c49 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -33,6 +33,11 @@ const router = createRouter({ name: 'profile', component: () => import('@/views/ProfileView.vue') }, + { + path: '/profil/rediger', + name: 'edit-profile', + component: () => import('@/views/EditProfileView.vue') + }, { path: '/sparemaal', name: 'goals', @@ -98,6 +103,11 @@ const router = createRouter({ name: 'configurations5', component: () => import('@/views/ConfigSpendingItemsTotalAmountView.vue') }, + { + path: '/konfigurasjonSteg6', + name: 'configurations6', + component: () => import('@/views/ConfigAccountNumberView.vue') + }, { path: '/forsteSparemaal', name: 'firstSavingGoal', @@ -112,6 +122,11 @@ const router = createRouter({ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/views/NotFoundView.vue') + }, + { + path: '/addAlternativeLogin', + name: 'addAlternativeLogin', + component: () => import('@/views/AddAlternativeLogin.vue') } ], scrollBehavior() { diff --git a/src/stores/accountStore.ts b/src/stores/accountStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..b80263eafa362581f6c2e235d544456ad887274c --- /dev/null +++ b/src/stores/accountStore.ts @@ -0,0 +1,35 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import authInterceptor from '@/services/authInterceptor' +import { AxiosError } from 'axios' + +export const useAccountStore = defineStore('account', { + state: () => ({ + errorMessage: ref<string>('') + }), + actions: { + async postAccount(accountType: 'SAVING' | 'SPENDING', accNumber: string, balance: number) { + const payload = { + accountType, + accNumber, + balance + } + + try { + const response = await authInterceptor.post('/accounts', payload) + console.log('Success:', response.data) + } catch (error) { + console.error('Error posting account:', error) + this.handleAxiosError(error) + } + }, + handleAxiosError(error: any) { + const axiosError = error as AxiosError + if (axiosError.response && axiosError.response.data) { + const errorData = axiosError.response.data as { message: string } + } else { + this.errorMessage = 'An unexpected error occurred' + } + } + } +}) diff --git a/src/stores/userConfigStore.ts b/src/stores/userConfigStore.ts index c2fa6032c4fdfaf6f3e130d465d9cb33dde3c256..6f1e0f1ccbe25f90f463aed9ef97ec9b63ac4319 100644 --- a/src/stores/userConfigStore.ts +++ b/src/stores/userConfigStore.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' +import { ref } from 'vue' import authInterceptor from '@/services/authInterceptor' -import axios from 'axios' +import { AxiosError } from 'axios' export const useUserConfigStore = defineStore('userConfig', { state: () => ({ @@ -11,7 +12,8 @@ export const useUserConfigStore = defineStore('userConfig', { type: string specificAmount: number generalAmount: number - }[] + }[], + errorMessage: ref<string>('') }), actions: { setExperience(value: string) { @@ -23,23 +25,28 @@ export const useUserConfigStore = defineStore('userConfig', { addChallengeTypeConfig(type: string, specificAmount: number, generalAmount: number) { this.challengeTypeConfigs.push({ type, specificAmount, generalAmount }) }, - async postUserConfig() { + postUserConfig() { const payload = { experience: this.experience, motivation: this.motivation, challengeTypeConfigs: Array.from(this.challengeTypeConfigs) } - try { - const response = await authInterceptor.post('/config/challenge', payload) - console.log('Success:', response.data) - } catch (error: unknown) { - if (axios.isAxiosError(error)) { - console.error('Axios error:', error.response?.data || error.message) - } else { - console.error('An unexpected error occurred:', error) - } - } + authInterceptor + .post('/config/challenge', payload) + .then((response) => { + console.log('Success:', response.data) + }) + .catch((error) => { + const axiosError = error as AxiosError + if (axiosError.response && axiosError.response.data) { + const errorData = axiosError.response.data as { message: string } + this.errorMessage = errorData.message || 'An error occurred' + } else { + this.errorMessage = 'An unexpected error occurred' + } + console.error('Axios error:', this.errorMessage) + }) } } }) diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts index 39c91976750e0651ed3e61980fefc86e3a8d10aa..e441008fea5b04d234fca42a9c253a21ce2b1590 100644 --- a/src/stores/userStore.ts +++ b/src/stores/userStore.ts @@ -6,6 +6,9 @@ import type { AxiosError } from 'axios' import axios from 'axios' import authInterceptor from "@/services/authInterceptor"; import type {Streak} from "@/types/streak"; +import type { CredentialRequestOptions } from '@/types/CredentialRequestOptions' +import { base64urlToUint8array, initialCheckStatus, uint8arrayToBase64url } from '@/util' +import type { CredentialCreationOptions } from '@/types/CredentialCreationOptions' export const useUserStore = defineStore('user', () => { const defaultUser: User = { @@ -41,7 +44,7 @@ export const useUserStore = defineStore('user', () => { user.value.lastname = lastname user.value.username = username - router.push({ name: 'configurations1' }) + router.push({ name: 'addAlternativeLogin' }) }) .catch((error) => { const axiosError = error as AxiosError @@ -94,10 +97,145 @@ export const useUserStore = defineStore('user', () => { } } + const bioRegister = async () => { + try { + const response = await authInterceptor.post('/auth/bioRegistration') + initialCheckStatus(response) + + const credentialCreateJson: CredentialCreationOptions = response.data + + const credentialCreateOptions: CredentialCreationOptions = { + publicKey: { + ...credentialCreateJson.publicKey, + challenge: base64urlToUint8array( + credentialCreateJson.publicKey.challenge as unknown as string + ), + user: { + ...credentialCreateJson.publicKey.user, + id: base64urlToUint8array( + credentialCreateJson.publicKey.user.id as unknown as string + ) + }, + excludeCredentials: credentialCreateJson.publicKey.excludeCredentials?.map( + (credential) => ({ + ...credential, + id: base64urlToUint8array(credential.id as unknown as string) + }) + ), + extensions: credentialCreateJson.publicKey.extensions + } + } + + const publicKeyCredential = (await navigator.credentials.create( + credentialCreateOptions + )) as PublicKeyCredential + + const publicKeyResponse = + publicKeyCredential.response as AuthenticatorAttestationResponse + const encodedResult = { + type: publicKeyCredential.type, + id: publicKeyCredential.id, + response: { + attestationObject: uint8arrayToBase64url(publicKeyResponse.attestationObject), + clientDataJSON: uint8arrayToBase64url(publicKeyResponse.clientDataJSON), + transports: publicKeyResponse.getTransports?.() || [] + }, + clientExtensionResults: publicKeyCredential.getClientExtensionResults() + } + + await authInterceptor + .post('/auth/finishBioRegistration', { credential: JSON.stringify(encodedResult) }) + .then((response) => { + router.push({ name: 'configurations1' }) + }) + } catch (error) { + router.push({ name: 'configurations1' }) + console.error(error) + } + } + + const bioLogin = async (username: string) => { + try { + const request = await axios.post(`http://localhost:8080/auth/bioLogin/${username}`) + + initialCheckStatus(request) + console.log(request) + + const credentialGetJson: CredentialRequestOptions = request.data + console.log(credentialGetJson) + + const credentialGetOptions: CredentialRequestOptions = { + publicKey: { + ...credentialGetJson.publicKey, + allowCredentials: + credentialGetJson.publicKey.allowCredentials && + credentialGetJson.publicKey.allowCredentials.map((credential) => ({ + ...credential, + id: base64urlToUint8array(credential.id as unknown as string) + })), + challenge: base64urlToUint8array( + credentialGetJson.publicKey.challenge as unknown as string + ), + extensions: credentialGetJson.publicKey.extensions + } + } + + const publicKeyCredential = (await navigator.credentials.get( + credentialGetOptions + )) as PublicKeyCredential + + // Extract response data based on the type of credential + const response = publicKeyCredential.response as AuthenticatorAssertionResponse + + const encodedResult = { + type: publicKeyCredential.type, + id: publicKeyCredential.id, + response: { + authenticatorData: + response.authenticatorData && + uint8arrayToBase64url(response.authenticatorData), + clientDataJSON: + response.clientDataJSON && uint8arrayToBase64url(response.clientDataJSON), + + signature: response.signature && uint8arrayToBase64url(response.signature), + userHandle: response.userHandle && uint8arrayToBase64url(response.userHandle) + }, + clientExtensionResults: publicKeyCredential.getClientExtensionResults() + } + console.log(encodedResult) + + await axios + .post('http://localhost:8080/auth/finishBioLogin/katanta1', { + credential: JSON.stringify(encodedResult) + }) + .then((response) => { + sessionStorage.setItem('accessToken', response.data.accessToken) + localStorage.setItem('refreshToken', response.data.refreshToken) + + user.value.firstname = response.data.firstName + user.value.lastname = response.data.lastName + user.value.username = response.data.username + + router.push({ name: 'home' }) + }) + .catch((error) => { + const axiosError = error as AxiosError + errorMessage.value = + (axiosError.response?.data as string) || 'An error occurred' + console.log('hei :' + errorMessage.value) + }) + } catch (error) { + // Handle errors + console.log(error) + } + } + return { register, login, logout, + bioLogin, + bioRegister, errorMessage, getUserStreak, streak, diff --git a/src/types/CredentialCreationOptions.ts b/src/types/CredentialCreationOptions.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6fdd7a76510ca4033a1c763a2ca9545797ab1b5 --- /dev/null +++ b/src/types/CredentialCreationOptions.ts @@ -0,0 +1,3 @@ +export interface CredentialCreationOptions { + publicKey: PublicKeyCredentialCreationOptions +} diff --git a/src/types/CredentialRequestOptions.ts b/src/types/CredentialRequestOptions.ts new file mode 100644 index 0000000000000000000000000000000000000000..4349a83cdfde9e60fe294adeef87dd4f1792087c --- /dev/null +++ b/src/types/CredentialRequestOptions.ts @@ -0,0 +1,3 @@ +export interface CredentialRequestOptions { + publicKey: PublicKeyCredentialRequestOptions +} diff --git a/src/types/profile.ts b/src/types/profile.ts new file mode 100644 index 0000000000000000000000000000000000000000..392aee69615beff857dc324e85cc4f77ea1f7911 --- /dev/null +++ b/src/types/profile.ts @@ -0,0 +1,19 @@ +export interface Profile { + id: number + firstName: string + lastName: string + email: string + username: string + password?: string + spendingAccount: { + accNumber?: number + accountType?: string + balance?: number + } + savingAccount: { + accNumber?: number + accountType?: string + balance?: number + } + badges?: object[] +} diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f6cbfc818ab356135d6acf232024019ea4fafe0 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,33 @@ +import base64js from 'base64-js' +import { type AxiosResponse } from 'axios' + +export function base64urlToUint8array(base64Bytes: string): Uint8Array { + const padding = '===='.substring(0, (4 - (base64Bytes.length % 4)) % 4) + return base64js.toByteArray((base64Bytes + padding).replace(/\//g, '_').replace(/\+/g, '-')) +} + +export function uint8arrayToBase64url(bytes: Uint8Array | ArrayBuffer | number[]): string { + if (bytes instanceof Uint8Array) { + return base64js + .fromByteArray(bytes) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') + } else { + return uint8arrayToBase64url(new Uint8Array(bytes)) + } +} + +export function checkStatus(response: AxiosResponse<any>): AxiosResponse<any> | undefined { + if (response.status !== 200) { + console.log('an error occurred: ', response.statusText) + return undefined + } else { + return response + } +} + +export function initialCheckStatus(response: AxiosResponse<any>): any { + checkStatus(response) + return response.data +} diff --git a/src/utilo.js b/src/utilo.js new file mode 100644 index 0000000000000000000000000000000000000000..4125c85af520a7d429032a90f80c9a4611f07740 --- /dev/null +++ b/src/utilo.js @@ -0,0 +1,28 @@ +import base64js from 'base64-js' + +export function base64urlToUint8array(base64Bytes) { + const padding = '===='.substring(0, (4 - (base64Bytes.length % 4)) % 4) + return base64js.toByteArray((base64Bytes + padding).replace(/\//g, '_').replace(/\+/g, '-')) +} +export function uint8arrayToBase64url(bytes) { + if (bytes instanceof Uint8Array) { + return base64js + .fromByteArray(bytes) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') + } else { + return uint8arrayToBase64url(new Uint8Array(bytes)) + } +} +export function checkStatus(response) { + if (response.status !== 200) { + console.log('an error occurred: ', response.body) + } else { + return response + } +} +export function initialCheckStatus(response) { + checkStatus(response) + return response.data +} diff --git a/src/views/AddAlternativeLogin.vue b/src/views/AddAlternativeLogin.vue new file mode 100644 index 0000000000000000000000000000000000000000..554f09f25915282f5675244784a6bb212457d036 --- /dev/null +++ b/src/views/AddAlternativeLogin.vue @@ -0,0 +1,48 @@ +<template> + <div class="alt-login-main"> + <h1>Alternativ innlogging</h1> + <div class="img-div"> + <img src="@/assets/bioAuthTouch.png" alt="bioAuthTouch" /> + <img src="@/assets/bioAuthFace.png" alt="bioAuthFace" /> + </div> + <h2>Vil du logge pÃ¥ med touch eller face id?</h2> + <div class="btn-div"> + <button @click="router.push('konfigurasjonSteg1')">Senere</button> + <button @click="userStore.bioRegister()">OK</button> + </div> + </div> +</template> +<script setup lang="ts"> +import { useUserStore } from '@/stores/userStore' +import router from '@/router' + +const userStore = useUserStore() +</script> + +<style scoped> +.alt-login-main { + max-width: 800px; + display: flex; + flex-direction: column; + align-items: center; +} + +.img-div { + display: flex; + justify-content: center; +} + +img { + width: 30%; +} + +img:first-child { + margin-right: 20px; +} + +button { + margin: 10px; + width: 100px; + height: 40px; +} +</style> diff --git a/src/views/CardTemplate.vue b/src/views/CardTemplate.vue new file mode 100644 index 0000000000000000000000000000000000000000..6fa731e67776d9e923ea99427b9a23893f3de385 --- /dev/null +++ b/src/views/CardTemplate.vue @@ -0,0 +1,9 @@ +<script lang="ts" setup></script> + +<template> + <div class="border rounded-xl shadow-lg overflow-hidden"> + <slot></slot> + </div> +</template> + +<style scoped></style> diff --git a/src/views/ConfigAccountNumberView.vue b/src/views/ConfigAccountNumberView.vue new file mode 100644 index 0000000000000000000000000000000000000000..652cf0d11ba68e92f47d8b6e14171faa565cf321 --- /dev/null +++ b/src/views/ConfigAccountNumberView.vue @@ -0,0 +1,114 @@ +<template> + <div + class="flex flex-col items-center justify-center min-h-screen md:pt-10 pt-4 pb-24 text-center" + > + <h1 class="mb-8 lg:mb-12 text-4xl font-bold"> + Legg til kontonummer for sparekonto og brukskonto + </h1> + <div + class="flex flex-col items-center justify-center bg-white rounded-lg p-8 shadow-lg w-full md:w-[45%]" + > + <div class="w-full mb-4"> + <label for="savingsAccount" class="block text-lg font-bold mb-2">Sparekonto</label> + <input + id="savingsAccount" + v-model="savingsAccount" + @input="restrictToNumbers($event as InputEvent, 'savings')" + @focus="removeFormatting('savings')" + @blur="applyFormatting('savings')" + class="w-full h-11 px-3 rounded-md text-xl focus:outline-none transition-colors border-2 border-gray-300" + type="text" + placeholder="Skriv inn ditt kontonummer..." + /> + </div> + <div class="w-full mb-4"> + <label for="spendingAccount" class="block text-lg font-bold mb-2">Brukskonto</label> + <input + id="spendingAccount" + v-model="spendingAccount" + @input="restrictToNumbers($event as InputEvent, 'spending')" + @focus="removeFormatting('spending')" + @blur="applyFormatting('spending')" + class="w-full h-11 px-3 rounded-md text-xl focus:outline-none transition-colors border-2 border-gray-300" + type="text" + placeholder="Skriv inn ditt kontonummer..." + /> + </div> + </div> + <div class="absolute bottom-36 right-2"> + <ContinueButtonComponent + @click="onButtonClick" + :disabled="!isFormValid" + class="px-10 py-3 text-2xl font-bold mb-4 mr-2" + ></ContinueButtonComponent> + </div> + </div> +</template> + +<script setup lang="ts"> +import { computed, ref } from 'vue' +import { useAccountStore } from '@/stores/accountStore' +import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' +import router from '@/router' + +const MAX_DIGITS = 11 +const accountStore = useAccountStore() + +const spendingAccount = ref('') +const savingsAccount = ref('') + +const isFormValid = computed(() => { + return ( + spendingAccount.value.replace(/\./g, '').length === MAX_DIGITS && + savingsAccount.value.replace(/\./g, '').length === MAX_DIGITS + ) +}) + +async function onButtonClick() { + const savingAccountNumber = savingsAccount.value.replace(/\./g, '') + const spendingAccountNumber = spendingAccount.value.replace(/\./g, '') + + await accountStore.postAccount('SAVING', savingAccountNumber, 0) + + await accountStore.postAccount('SPENDING', spendingAccountNumber, 0) + + await router.push({ name: 'home' }) +} + +function restrictToNumbers(event: InputEvent, type: string) { + const inputValue = (event.target as HTMLInputElement)?.value + if (inputValue !== undefined) { + const sanitizedValue = inputValue.replace(/\D/g, '') + const truncatedValue = sanitizedValue.slice(0, MAX_DIGITS) + if (type === 'spending') { + spendingAccount.value = truncatedValue + } else { + savingsAccount.value = truncatedValue + } + } +} + +function applyFormatting(type: string) { + if (type === 'spending') { + spendingAccount.value = formatAccount(spendingAccount.value) + } else { + savingsAccount.value = formatAccount(savingsAccount.value) + } +} + +function removeFormatting(type: string) { + if (type === 'spending') { + spendingAccount.value = removeFormat(spendingAccount.value) + } else { + savingsAccount.value = removeFormat(savingsAccount.value) + } +} + +function formatAccount(value: string): string { + return value.replace(/\D/g, '').replace(/^(.{4})(.{2})(.*)$/, '$1.$2.$3') +} + +function removeFormat(value: string): string { + return value.replace(/\./g, '') +} +</script> diff --git a/src/views/ConfigFamiliarWithSavingsView.vue b/src/views/ConfigFamiliarWithSavingsView.vue index 3b3c8a4d1c6b6143ce1100e1e850a97a82e27988..ecbd66aa41acbc1aa29f2fd9405ec17cb0c00213 100644 --- a/src/views/ConfigFamiliarWithSavingsView.vue +++ b/src/views/ConfigFamiliarWithSavingsView.vue @@ -1,7 +1,9 @@ <template> <div class="flex flex-col items-center justify-center min-h-screen px-4 text-center"> - <h1 class="mb-16 text-4xl font-bold lg:mb-20">Hvor kjent er du med sparing fra før?</h1> - <div class="grid grid-cols-1 gap-14 mb-20 md:grid-cols-3"> + <h1 class="mb-8 text-2xl font-bold sm:mb-16 sm:text-4xl"> + Hvor kjent er du med sparing fra før? + </h1> + <div class="grid grid-cols-1 gap-8 mb-16 sm:gap-14 sm:mb-20 md:grid-cols-3"> <div :class="{ 'border-[var(--green)] border-4': selectedOption === 'litt', @@ -39,7 +41,7 @@ <ContinueButtonComponent :disabled="selectedOption === null" @click="onButtonClick" - class="px-10 py-3 text-2xl self-end mb-4 mt-0" + class="px-10 py-3 text-2xl self-end" ></ContinueButtonComponent> </div> </template> @@ -67,8 +69,6 @@ const selectOption = (option: string) => { case 'godt': experienceValue = 'VERY_HIGH' break - default: - experienceValue = 'VERY_LOW' } userConfigStore.setExperience(experienceValue) @@ -76,7 +76,7 @@ const selectOption = (option: string) => { const onButtonClick = () => { if (selectedOption.value) { - router.push('/konfigurasjonSteg3') + router.push({ name: 'configurations3' }) } else { console.error('No option selected') } diff --git a/src/views/ConfigHabitChangeView.vue b/src/views/ConfigHabitChangeView.vue index c686e26112e56a2d298e5e80d5cb59f0b8736f83..a1e8b37350d385d2c3f31db77f2b8c81b56b6e41 100644 --- a/src/views/ConfigHabitChangeView.vue +++ b/src/views/ConfigHabitChangeView.vue @@ -69,8 +69,6 @@ const selectOption = (option: string) => { case 'store': motivationValue = 'VERY_HIGH' break - default: - motivationValue = 'VERY_LOW' } userConfigStore.setMotivation(motivationValue) @@ -78,7 +76,7 @@ const selectOption = (option: string) => { const onButtonClick = () => { if (selectedOption.value) { - router.push('/konfigurasjonSteg2') + router.push({ name: 'configurations2' }) } else { console.error('No option selected') } diff --git a/src/views/ConfigSpendingItemsAmountView.vue b/src/views/ConfigSpendingItemsAmountView.vue index 625bf5335a0e50a61c1db4cb2a388d954430a73e..aa77feb1371de00b5a70dfe7815dd86e77f939c5 100644 --- a/src/views/ConfigSpendingItemsAmountView.vue +++ b/src/views/ConfigSpendingItemsAmountView.vue @@ -1,59 +1,74 @@ <template> <div class="flex flex-col items-center justify-center min-h-screen px-4 text-center relative"> - <h1 class="mb-8 lg:mb-12 text-4xl font-bold">Hvor mye bruker du per kjøp pÃ¥ ...</h1> - <div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-6"> - <div class="flex flex-col items-center bg-white rounded-lg p-8 shadow-lg w-full"> + <h1 class="mb-8 text-2xl font-bold sm:mb-16 sm:text-4xl"> + Hvor mye bruker du per kjøp pÃ¥ ... + </h1> + <div class="w-full flex justify-center"> + <div :class="[showSecondBox ? 'md:grid md:grid-cols-2 md:gap-4 sm:gap-8 mb-6' : '']"> <div - v-for="(option, index) in options.slice(0, 6)" - :key="`option-${index}`" - class="w-full my-4" + v-if="showFirstBox" + class="flex flex-col items-center bg-white rounded-lg p-4 sm:p-8 shadow-lg" + :class="showSecondBox ? 'w-full' : 'w-full md:w-1/2 mx-auto'" + :style="{ minWidth: '400px', maxWidth: '400px' }" > - <div class="flex justify-between items-center"> - <p class="text-xl font-bold mr-4">{{ option.type }}</p> - <div class="flex items-center w-2/3"> - <input - v-model="amounts[index]" - @input="filterAmount(index, $event)" - class="h-11 px-3 rounded-md text-lg focus:outline-none border-2 w-full" - :class="{ - 'border-gray-300': !amounts[index], - 'border-[var(--green)]': amounts[index] - }" - /> - <p class="text-xl font-bold ml-2">kr</p> + <div + v-for="(option, index) in firstBoxOptions" + :key="`first-option-${index}`" + class="w-full my-4" + > + <div class="flex justify-between items-center"> + <p class="text-xl font-bold mr-4">{{ option.type }}</p> + <div class="flex items-center w-2/3"> + <input + v-model="amounts[index]" + @input="filterAmount(index, $event)" + class="h-11 px-3 rounded-md text-lg focus:outline-none border-2 w-full" + :class="{ + 'border-gray-300': !amounts[index], + 'border-[var(--green)]': amounts[index] + }" + /> + <p class="text-xl font-bold ml-2">kr</p> + </div> </div> </div> </div> - </div> - <div class="flex flex-col items-center bg-white rounded-lg p-8 shadow-lg w-full"> <div - v-for="(option, index) in options.slice(6, 12)" - :key="`option-${index}`" - class="w-full my-4" + v-if="showSecondBox" + class="flex flex-col items-center bg-white rounded-lg p-4 sm:p-8 shadow-lg" + :class="showSecondBox ? 'w-full' : 'w-full md:w-1/2 mx-auto'" + :style="{ minWidth: '400px', maxWidth: '400px' }" > - <div class="flex justify-between items-center"> - <p class="text-xl font-bold mr-4">{{ option.type }}</p> - <div class="flex items-center w-2/3"> - <input - v-model="amounts[index + 6]" - @input="filterAmount(index + 6, $event)" - class="h-11 px-3 rounded-md text-lg focus:outline-none border-2 w-full" - :class="{ - 'border-gray-300': !amounts[index + 6], - 'border-[var(--green)]': amounts[index + 6] - }" - /> - <p class="text-xl font-bold ml-2">kr</p> + <div + v-for="(option, index) in secondBoxOptions" + :key="`second-option-${index}`" + class="w-full my-4" + > + <div class="flex justify-between items-center"> + <p class="text-xl font-bold mr-4">{{ option.type }}</p> + <div class="flex items-center w-2/3"> + <input + v-model="amounts[index + firstBoxOptions.length]" + @input="filterAmount(index + firstBoxOptions.length, $event)" + class="h-11 px-3 rounded-md text-lg focus:outline-none border-2 w-full" + :class="{ + 'border-gray-300': !amounts[index + firstBoxOptions.length], + 'border-[var(--green)]': + amounts[index + firstBoxOptions.length] + }" + /> + <p class="text-xl font-bold ml-2">kr</p> + </div> </div> </div> </div> </div> </div> - <div class="absolute bottom-36 right-4"> + <div class="w-full text-right"> <ContinueButtonComponent @click="onButtonClick" :disabled="!isAllAmountsFilled" - class="px-10 py-3 text-2xl font-bold mb-4" + class="px-10 py-3 text-2xl font-bold mb-20 mt-10 sm:mb-12 sm:mt-10" ></ContinueButtonComponent> </div> </div> @@ -77,7 +92,7 @@ const onButtonClick = () => { userConfigStore.challengeTypeConfigs[index].specificAmount = parseFloat(amounts.value[index]) || 0 }) - router.push('/konfigurasjonSteg5') + router.push({ name: 'configurations5' }) } const filterAmount = (index: number, event: Event) => { @@ -86,4 +101,10 @@ const filterAmount = (index: number, event: Event) => { filteredValue = filteredValue.replace(/(,.*?),/g, '$1').replace(/,+/g, ',') amounts.value[index] = filteredValue } + +const firstBoxOptions = computed(() => options.value.slice(0, 6)) +const secondBoxOptions = computed(() => (options.value.length > 6 ? options.value.slice(6) : [])) + +const showFirstBox = computed(() => options.value.length > 0) +const showSecondBox = computed(() => options.value.length > 6) </script> diff --git a/src/views/ConfigSpendingItemsTotalAmountView.vue b/src/views/ConfigSpendingItemsTotalAmountView.vue index d24cb4f6c652780a935a810a34c0ef3b489a2588..098ba86faec729bcbc8450ad91268d7e65d82536 100644 --- a/src/views/ConfigSpendingItemsTotalAmountView.vue +++ b/src/views/ConfigSpendingItemsTotalAmountView.vue @@ -1,61 +1,74 @@ <template> - <div class="flex flex-col items-center justify-center min-h-screen px-4 text-center"> - <h1 class="mb-8 lg:mb-12 text-4xl font-bold">Hvor mye bruker du totalt per uke pÃ¥ ...</h1> - <div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-6"> - <div class="flex flex-col items-center bg-white rounded-lg p-8 shadow-lg w-full"> + <div class="flex flex-col items-center justify-center min-h-screen px-4 text-center relative"> + <h1 class="mb-8 text-2xl font-bold sm:mb-16 sm:text-4xl"> + Hvor mye bruker du per uke pÃ¥ ... + </h1> + <div class="w-full flex justify-center"> + <div :class="[showSecondBox ? 'md:grid md:grid-cols-2 md:gap-4 sm:gap-8 mb-6' : '']"> <div - v-for="(option, index) in options.slice(0, 6)" - :key="`option-${index}`" - class="w-full my-4" + v-if="showFirstBox" + class="flex flex-col items-center bg-white rounded-lg p-4 sm:p-8 shadow-lg" + :class="showSecondBox ? 'w-full' : 'w-full md:w-1/2 mx-auto'" + :style="{ minWidth: '400px', maxWidth: '400px' }" > - <div class="flex justify-between items-center"> - <p class="text-xl font-bold mr-4">{{ option.type }}</p> - <div class="flex items-center w-2/3"> - <input - type="text" - v-model="amounts[index]" - @input="($event) => filterAmount(index, $event)" - class="h-11 px-3 rounded-md text-lg focus:outline-none border-2 w-full" - :class="{ - 'border-gray-300': !amounts[index], - 'border-[var(--green)]': amounts[index] - }" - /> - <p class="text-xl font-bold ml-2">kr</p> + <div + v-for="(option, index) in firstBoxOptions" + :key="`first-option-${index}`" + class="w-full my-4" + > + <div class="flex justify-between items-center"> + <p class="text-xl font-bold mr-4">{{ option.type }}</p> + <div class="flex items-center w-2/3"> + <input + v-model="amounts[index]" + @input="filterAmount(index, $event)" + class="h-11 px-3 rounded-md text-lg focus:outline-none border-2 w-full" + :class="{ + 'border-gray-300': !amounts[index], + 'border-[var(--green)]': amounts[index] + }" + /> + <p class="text-xl font-bold ml-2">kr</p> + </div> </div> </div> </div> - </div> - <div class="flex flex-col items-center bg-white rounded-lg p-8 shadow-lg w-full"> <div - v-for="(option, index) in options.slice(6, 12)" - :key="`option-${index}`" - class="w-full my-4" + v-if="showSecondBox" + class="flex flex-col items-center bg-white rounded-lg p-4 sm:p-8 shadow-lg" + :class="showSecondBox ? 'w-full' : 'w-full md:w-1/2 mx-auto'" + :style="{ minWidth: '400px', maxWidth: '400px' }" > - <div class="flex justify-between items-center"> - <p class="text-xl font-bold mr-4">{{ option.type }}</p> - <div class="flex items-center w-2/3"> - <input - type="text" - v-model="amounts[index + 6]" - @input="($event) => filterAmount(index + 6, $event)" - class="h-11 px-3 rounded-md text-lg focus:outline-none border-2 w-full" - :class="{ - 'border-gray-300': !amounts[index + 6], - 'border-[var(--green)]': amounts[index + 6] - }" - /> - <p class="text-xl font-bold ml-2">kr</p> + <div + v-for="(option, index) in secondBoxOptions" + :key="`second-option-${index}`" + class="w-full my-4" + > + <div class="flex justify-between items-center"> + <p class="text-xl font-bold mr-4">{{ option.type }}</p> + <div class="flex items-center w-2/3"> + <input + v-model="amounts[index + firstBoxOptions.length]" + @input="filterAmount(index + firstBoxOptions.length, $event)" + class="h-11 px-3 rounded-md text-lg focus:outline-none border-2 w-full" + :class="{ + 'border-gray-300': !amounts[index + firstBoxOptions.length], + 'border-[var(--green)]': + amounts[index + firstBoxOptions.length] + }" + /> + <p class="text-xl font-bold ml-2">kr</p> + </div> </div> </div> </div> </div> </div> - <div class="absolute bottom-24 right-4"> + <div class="w-full text-right"> <ContinueButtonComponent @click="onButtonClick" :disabled="!isAllAmountsFilled" - class="px-10 py-3 text-2xl font-bold mb-4" + class="px-10 py-3 text-2xl font-bold mb-20 mt-10 sm:mb-12 sm:mt-10" ></ContinueButtonComponent> </div> </div> @@ -80,8 +93,8 @@ const onButtonClick = async () => { parseFloat(amounts.value[index]) || 0 }) - await userConfigStore.postUserConfig() - router.push('/hjem') + userConfigStore.postUserConfig() + await router.push({ name: 'configurations6' }) } const filterAmount = (index: number, event: Event) => { @@ -90,4 +103,10 @@ const filterAmount = (index: number, event: Event) => { filteredValue = filteredValue.replace(/(,.*?),/g, '$1').replace(/,+/g, ',') amounts.value[index] = filteredValue } + +const firstBoxOptions = computed(() => options.value.slice(0, 6)) +const secondBoxOptions = computed(() => (options.value.length > 6 ? options.value.slice(6) : [])) + +const showFirstBox = computed(() => options.value.length > 0) +const showSecondBox = computed(() => options.value.length > 6) </script> diff --git a/src/views/ConfigSpendingItemsView.vue b/src/views/ConfigSpendingItemsView.vue index 2226cb066b6cc1ed6a880672f6f3223bd33bbb5d..fbeda5f09011b4a3f8639d752c02eab917c32e14 100644 --- a/src/views/ConfigSpendingItemsView.vue +++ b/src/views/ConfigSpendingItemsView.vue @@ -1,9 +1,9 @@ <template> <div class="flex flex-col items-center justify-center min-h-screen text-center"> - <h1 class="mb-8 lg:mb-12 text-4xl font-bold">Hva bruker du mye penger pÃ¥?</h1> + <h1 class="mb-8 text-2xl font-bold sm:mb-16 sm:text-4xl">Hva bruker du mye penger pÃ¥?</h1> <div class="flex flex-wrap justify-center gap-8 mb-8"> <div - class="flex flex-col items-center justify-center bg-white rounded-lg p-8 shadow-lg w-full md:w-[45%]" + class="flex flex-col items-center justify-center bg-white rounded-lg sm:p-8 shadow-lg sm:w-full md:w-[45%]" > <div v-for="buttonText in [ @@ -32,7 +32,7 @@ </div> </div> <div - class="flex flex-col items-center justify-center bg-white rounded-lg p-8 shadow-lg w-full md:w-[45%]" + class="flex flex-col items-center justify-center bg-white rounded-lg sm:p-8 shadow-lg sm:w-full md:w-[45%]" > <div v-for="(option, index) in customOptions" @@ -41,18 +41,23 @@ > <input v-model="customOptions[index]" - class="w-full md:w-64 h-11 px-3 rounded-md text-xl focus:outline-none transition-colors border-2 border-gray-300" + :class="[ + 'w-full md:w-64 h-11 px-3 rounded-md text-xl focus:outline-none transition-colors border-2', + customOptions[index].trim() !== '' + ? 'border-[var(--green)]' + : 'border-gray-300' + ]" type="text" :placeholder="'Annet ' + ' ...'" /> </div> </div> </div> - <div class="w-full text-right mb-0 mt-0" style="position: relative; top: -92px; right: 8px"> + <div class="w-full text-right"> <ContinueButtonComponent @click="onButtonClick" :disabled="!isFormValid" - class="px-10 py-3 text-2xl font-bold mb-4 mr-2" + class="px-10 py-3 text-2xl font-bold mt-36 mr-4 sm:mb-12 sm:mt-10" ></ContinueButtonComponent> </div> </div> @@ -68,24 +73,27 @@ const userConfigStore = useUserConfigStore() const selectedOptions = ref<string[]>([]) const customOptions = ref(['', '', '', '', '', '']) -const toggleOption = (option: string, isCustom: boolean = false) => { - if (!isCustom) { - const index = selectedOptions.value.indexOf(option) - if (index === -1) { - selectedOptions.value.push(option) - } else { - selectedOptions.value.splice(index, 1) - } +const toggleOption = (option: string) => { + const index = selectedOptions.value.indexOf(option) + if (index === -1) { + selectedOptions.value.push(option) + } else { + selectedOptions.value.splice(index, 1) } } const isFormValid = computed(() => { const predefinedSelected = selectedOptions.value.length > 0 const customFilled = customOptions.value.some((option) => option.trim() !== '') - return predefinedSelected || (customFilled && predefinedSelected) + return predefinedSelected || customFilled }) const onButtonClick = () => { + if (!isFormValid.value) { + console.error('Form is not valid') + return + } + const predefinedChallengeTypes = selectedOptions.value.map((option) => ({ type: option, specificAmount: 0, @@ -101,7 +109,6 @@ const onButtonClick = () => { })) userConfigStore.challengeTypeConfigs = [...predefinedChallengeTypes, ...customChallengeTypes] - console.log('Selected Challenge Types:', userConfigStore.challengeTypeConfigs) - router.push('/konfigurasjonSteg4') + router.push({ name: 'configurations4' }) } </script> diff --git a/src/views/EditProfileView.vue b/src/views/EditProfileView.vue new file mode 100644 index 0000000000000000000000000000000000000000..82da35481c774c789baffd3264b4a36cbbe45614 --- /dev/null +++ b/src/views/EditProfileView.vue @@ -0,0 +1,254 @@ +<script lang="ts" setup> +import authInterceptor from '@/services/authInterceptor' +import { computed, onMounted, ref } from 'vue' +import type { Profile } from '@/types/profile' +import CardTemplate from '@/views/CardTemplate.vue' +import router from '@/router' +import ToolTip from '@/components/ToolTip.vue' +import InteractiveSpare from '@/components/InteractiveSpare.vue' + +const profile = ref<Profile>({ + id: 0, + firstName: '', + lastName: '', + email: '', + username: '', + password: '', + spendingAccount: { + accNumber: undefined, + balance: 0 + }, + savingAccount: { + accNumber: undefined, + balance: 0 + } +}) + +const updatePassword = ref<boolean>(false) +const confirmPassword = ref<string>('') +const errorMessage = ref<string>('') + +const nameRegex = /^[æÆøØåÅa-zA-Z,.'-][æÆøØåÅa-zA-Z ,.'-]{1,29}$/ +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,30}$/ +const accountNumberRegex = /^\d{11}$/ + +const isFirstNameValid = computed( + () => nameRegex.test(profile.value.firstName) && profile.value.firstName +) +const isLastNameValid = computed( + () => nameRegex.test(profile.value.lastName) && profile.value.lastName +) +const isEmailValid = computed(() => emailRegex.test(profile.value.email)) +const isUsernameValid = computed(() => usernameRegex.test(profile.value.username)) +const isPasswordValid = computed(() => passwordRegex.test(profile.value.password || '')) +const isSpendingAccountValid = computed(() => + accountNumberRegex.test(profile.value.spendingAccount.accNumber?.toString() || '') +) +const isSavingAccountValid = computed(() => + accountNumberRegex.test(profile.value.savingAccount.accNumber?.toString() || '') +) + +const isFormInvalid = computed( + () => + [ + isFirstNameValid, + isLastNameValid, + isEmailValid, + isUsernameValid, + isSpendingAccountValid, + isSavingAccountValid + ].some((v) => !v.value) || + (updatePassword.value + ? profile.value.password !== confirmPassword.value || profile.value.password === '' + : false) +) + +onMounted(async () => { + await authInterceptor('/profile') + .then((response) => { + profile.value = response.data + console.log(profile.value) + }) + .catch((error) => { + return console.log(error) + }) +}) + +const saveChanges = async () => { + if (isFormInvalid.value) { + errorMessage.value = 'Vennligst fyll ut alle feltene riktig' + return + } + + if (!updatePassword.value) { + delete profile.value.password + } + + await authInterceptor + .put('/profile', profile.value) + .then(() => { + router.back() + }) + .catch((error) => { + errorMessage.value = error.response.data.message + }) +} +</script> + +<template> + <div class="w-full flex px-10 justify-center"> + <div class="flex flex-row flex-wrap justify-center w-full max-w-screen-xl gap-20"> + <div class="flex flex-col max-w-96 w-full gap-5"> + <h1>Rediger profil</h1> + <div class="w-full flex flex-row gap-5 justify-between justify-items-end"> + <div class="flex flex-col justify-center"> + <button class="h-min bg-transparent text-4xl" v-text="'⬅ï¸'" /> + </div> + <div class="w-32 h-32 border-black border-2 rounded-full shrink-0" /> + <div class="flex flex-col justify-center"> + <button class="h-min bg-transparent text-4xl" v-text="'âž¡ï¸'" /> + </div> + </div> + + <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. 1-30 characters long'" + /> + </div> + <input + v-model="profile.firstName" + :class="{ 'bg-green-200': isFirstNameValid }" + name="firstname" + placeholder="Skriv inn fornavn" + type="text" + /> + </div> + <div class="flex flex-col"> + <div class="flex flex-row justify-between mx-4"> + <p>Etternavn*</p> + <ToolTip + :message="'Must include only letters, spaces, commas, apostrophes, periods, and hyphens. 1-30 characters long'" + /> + </div> + <input + v-model="profile.lastName" + :class="{ 'bg-green-200': isLastNameValid }" + name="lastname" + 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="'Valid email: Starts with Norwegian letters, numbers, or special characters. Includes \@\ followed by a domain. Ends with 2-7 letters.'" + /> + </div> + <input + v-model="profile.email" + :class="{ 'bg-green-200': isEmailValid }" + name="email" + 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="profile.username" + :class="{ 'bg-green-200': isUsernameValid }" + name="username" + placeholder="Skriv inn brukernavn" + type="text" + /> + </div> + <div class="flex flex-col"> + <div class="flex flex-row justify-between mx-4"> + <div class="flex flex-row gap-2"> + <p>Endre passord</p> + <input v-model="updatePassword" type="checkbox" /> + </div> + <ToolTip + v-if="updatePassword" + :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> + <input + v-if="updatePassword" + v-model="profile.password" + :class="{ 'bg-green-200': isPasswordValid }" + class="w-full" + name="password" + placeholder="Skriv inn passord" + /> + <input + v-if="updatePassword" + v-model="confirmPassword" + :class="{ 'bg-red-200': profile.password !== confirmPassword }" + class="mt-2" + name="confirm" + placeholder="Bekreft passord" + type="password" + /> + </div> + + <p v-if="errorMessage" class="text-red-500" v-text="errorMessage" /> + </div> + <div class="flex flex-col justify-end max-w-96 w-full gap-5"> + <InteractiveSpare + :png-size="10" + :speech="['Her kan du endre pÃ¥ profilen din!']" + direction="left" + /> + + <CardTemplate> + <div class="bg-red-300"> + <p class="font-bold mx-3" v-text="'Brukskonto'" /> + </div> + <input + v-model="profile.spendingAccount.accNumber" + :class="{ 'bg-green-200': isSpendingAccountValid }" + class="border-2 rounded-none rounded-b-xl w-full" + placeholder="Kontonummer" + type="number" + /> + </CardTemplate> + + <CardTemplate> + <div class="bg-red-300"> + <p class="font-bold mx-3" v-text="'Sparekonto'" /> + </div> + <input + v-model="profile.savingAccount.accNumber" + :class="{ 'bg-green-200': isSavingAccountValid }" + class="border-2 rounded-none rounded-b-xl w-full" + placeholder="Kontonummer" + type="number" + /> + </CardTemplate> + + <div class="flex flex-row justify-between"> + <button class="bg-button-other" @click="router.back()" v-text="'Avbryt'" /> + <button + :disabled="isFormInvalid" + @click="saveChanges" + v-text="'Lagre endringer'" + /> + </div> + </div> + </div> + </div> +</template> + +<style scoped></style> diff --git a/src/views/ProfileView.vue b/src/views/ProfileView.vue index 255824275b207f62108888007491f385b3b8e1d2..436e522c7c815ad6cb5725724b76a8f76779d75d 100644 --- a/src/views/ProfileView.vue +++ b/src/views/ProfileView.vue @@ -1,21 +1,115 @@ <script lang="ts" setup> import authInterceptor from '@/services/authInterceptor' -import { onMounted } from 'vue' +import { computed, onMounted, ref } from 'vue' +import type { Profile } from '@/types/profile' +import CardTemplate from '@/views/CardTemplate.vue' +import InteractiveSpare from '@/components/InteractiveSpare.vue' +import type { Challenge } from '@/types/challenge' +import type { Goal } from '@/types/goal' +import CardGoal from '@/components/CardGoal.vue' +import router from '@/router' + +const profile = ref<Profile>() +const completedGoals = ref<Goal[]>([]) +const completedChallenges = ref<Challenge[]>([]) onMounted(async () => { - await authInterceptor - .get('/config') + await authInterceptor('/profile') + .then((response) => { + profile.value = response.data + console.log(profile.value) + }) + .catch((error) => { + return console.log(error) + }) + + await authInterceptor(`/goals/completed?page=0&size=3`) .then((response) => { - console.log(response.data) + completedGoals.value = response.data.content }) .catch((error) => { - console.log(error) + return console.log(error) }) + + await authInterceptor('/challenges/completed?page=0&size=3') + .then((response) => { + completedChallenges.value = response.data.content + }) + .catch((error) => { + return console.log(error) + }) +}) + +const welcome = computed(() => { + return [`Velkommen, ${profile.value?.firstName} ${profile.value?.lastName} !`] }) </script> <template> - <h1>Din profil</h1> + <div class="w-full flex px-10 justify-center"> + <div class="flex flex-row flex-wrap justify-center w-full max-w-screen-xl gap-20"> + <div class="flex flex-col max-w-96 w-full gap-5"> + <h1>Profile</h1> + <div class="flex flex-row gap-5"> + <div class="w-32 h-32 border-black border-2 rounded-full shrink-0" /> + <div class="w-full flex flex-col justify-between"> + <h3 class="font-thin my-0">{{ profile?.username }}</h3> + <h3 class="font-thin my-0"> + {{ profile?.firstName + ' ' + profile?.lastName }} + </h3> + <h3 class="font-thin my-0">{{ profile?.email }}</h3> + </div> + </div> + + <h3 class="font-bold" v-text="'Du har spart ' + '< totalSaved >' + 'kr'" /> + + <CardTemplate> + <div class="bg-red-300"> + <p class="font-bold mx-3" v-text="'Brukskonto'" /> + </div> + <p + class="mx-3" + v-text="profile?.spendingAccount.accNumber || 'Ingen brukskonto oppkoblet'" + /> + </CardTemplate> + + <CardTemplate> + <div class="bg-red-300"> + <p class="font-bold mx-3" v-text="'Sparekonto'" /> + </div> + <p + class="mx-3" + v-text="profile?.savingAccount.accNumber || 'Ingen sparekonto oppkoblet'" + /> + </CardTemplate> + + <button @click="router.push({ name: 'edit-profile' })" v-text="'Rediger bruker'" /> + </div> + + <div class="flex flex-col"> + <InteractiveSpare :png-size="10" :speech="welcome" direction="left" /> + <div class="flex flex-row justify-between mx-4"> + <p class="font-bold">Fullførte sparemÃ¥l</p> + <a class="hover:p-0 cursor-pointer" v-text="'Se alle'" /> + </div> + <CardTemplate class="p-4 flex flex-row flex-wrap justify-center gap-2 mb-4 mt-2"> + <CardGoal v-for="goal in completedGoals" :key="goal.id" :goal-instance="goal" /> + </CardTemplate> + + <div class="flex flex-row justify-between mx-4"> + <p class="font-bold">Fullførte utfordringer</p> + <a class="hover:p-0 cursor-pointer" v-text="'Se alle'" /> + </div> + <CardTemplate class="p-4 flex flex-row flex-wrap justify-center gap-2 mb-4 mt-2"> + <CardGoal + v-for="challenge in completedChallenges" + :key="challenge.id" + :goal-instance="challenge" + /> + </CardTemplate> + </div> + </div> + </div> </template> <style scoped></style>