diff --git a/.env b/.env new file mode 100644 index 0000000000000000000000000000000000000000..d6e1cfd8b65b83199cf543cf5dab04b77a857b1e --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_BACKEND_URL = "http://localhost:8080" \ No newline at end of file diff --git a/cypress/e2e/example.cy.js b/cypress/e2e/example.cy.js deleted file mode 100644 index 7a8c909fd86b8ae6090e0e060887805d13b622be..0000000000000000000000000000000000000000 --- a/cypress/e2e/example.cy.js +++ /dev/null @@ -1,8 +0,0 @@ -// https://docs.cypress.io/api/introduction/api.html - -describe('My First Test', () => { - it('visits the app root url', () => { - cy.visit('/') - cy.contains('h1', 'You did it!') - }) -}) diff --git a/cypress/e2e/login.cy.js b/cypress/e2e/login.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..50b8b978bca4f56e07c750efe52a48a66910ad9b --- /dev/null +++ b/cypress/e2e/login.cy.js @@ -0,0 +1,16 @@ +describe('Login fails with wrong credentials', () => { + it('passes', () => { + cy.visit('http://localhost:4173/login') + cy.get('#login-button').trigger('click') + cy.get('#error-message').contains("Kunne ikke logge inn!") + + cy.get('#email-input').type('en bruker som ikke finnes') + cy.get('#login-button').trigger('click') + cy.get('#error-message').contains("Kunne ikke logge inn!") + + cy.get('#password-input').type('hei') + cy.get('#login-button').trigger('click') + cy.get('#error-message').contains("Kunne ikke logge inn!") + }) + +}) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 67f061c2071489f5c61d3ca8d3d25f51c47caa3a..c8c1d66ed8d37a545dfbe8b073ea9da453dd1042 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "jwt-decode": "^3.1.2", "pinia": "^2.0.28", + "pinia-plugin-persistedstate": "^3.1.0", "vue": "^3.2.45", "vue-router": "^4.1.6" }, @@ -2355,6 +2357,11 @@ "verror": "1.10.0" } }, + "node_modules/jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "node_modules/lazy-ass": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", @@ -2815,6 +2822,14 @@ } } }, + "node_modules/pinia-plugin-persistedstate": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-3.1.0.tgz", + "integrity": "sha512-8UN+vYMEPBdgNLwceY08mi5olI0wkYaEb8b6hD6xW7SnBRuPydWHlEhZvUWgNb/ibuf4PvufpvtS+dmhYjJQOw==", + "peerDependencies": { + "pinia": "^2.0.0" + } + }, "node_modules/pinia/node_modules/vue-demi": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.0.tgz", diff --git a/package.json b/package.json index 0839dc49cac787f26247f7a9f784a8662c78170a..b392ca412259eca234815b99c796379d76c9f257 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "test:e2e:dev": "start-server-and-test 'vite dev --port 4173' :4173 'cypress run --e2e'" }, "dependencies": { + "jwt-decode": "^3.1.2", "pinia": "^2.0.28", + "pinia-plugin-persistedstate": "^3.1.0", "vue": "^3.2.45", "vue-router": "^4.1.6" }, diff --git a/src/App.vue b/src/App.vue index e864195002371619c22d0454351235745b2a4f3f..e953209cd7476b00def70c2510a0687c00c96a3c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,19 +4,6 @@ import HelloWorld from './components/HelloWorld.vue' </script> <template> - <header> - <img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" /> - - <div class="wrapper"> - <HelloWorld msg="You did it!" /> - - <nav> - <RouterLink to="/">Home</RouterLink> - <RouterLink to="/about">About</RouterLink> - </nav> - </div> - </header> - <RouterView /> </template> diff --git a/src/assets/base.css b/src/assets/base.css index 71dc55a3cb5a72589496743a327c738ead3e1c83..a0d4bbfe3b6d78b27f8bc1ec398f6e74d190e3a1 100644 --- a/src/assets/base.css +++ b/src/assets/base.css @@ -36,19 +36,6 @@ --section-gap: 160px; } -@media (prefers-color-scheme: dark) { - :root { - --color-background: var(--vt-c-black); - --color-background-soft: var(--vt-c-black-soft); - --color-background-mute: var(--vt-c-black-mute); - - --color-border: var(--vt-c-divider-dark-2); - --color-border-hover: var(--vt-c-divider-dark-1); - - --color-heading: var(--vt-c-text-dark-1); - --color-text: var(--vt-c-text-dark-2); - } -} *, *::before, diff --git a/src/components/icons/logo.png b/src/components/icons/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..932a31c570979e3d4453e761d6e0a97f9b9938da Binary files /dev/null and b/src/components/icons/logo.png differ diff --git a/src/main.js b/src/main.js index 4fb24b7e0e9bae1121b31644f14a63ea8aec98da..26fcc4ddaa8246e0e2ba5800912affe8e8a1d62f 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,6 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' +import piniaPluginPersistedState from "pinia-plugin-persistedstate"; import App from './App.vue' import router from './router' @@ -8,7 +9,10 @@ import './assets/main.css' const app = createApp(App) -app.use(createPinia()) +const pinia = createPinia(); +pinia.use(piniaPluginPersistedState); + +app.use(pinia) app.use(router) app.mount('#app') diff --git a/src/router/index.js b/src/router/index.js index a49ae507f39bdb792025d7c4bd1573b876e8cc96..5d1e0dfe3dd59b3431e97d04b7e31c5ed260d93c 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,5 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' +import LoginView from '../views/LoginView.vue' +import SelectProfileView from '../views/SelectProfileView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -10,12 +12,14 @@ const router = createRouter({ component: HomeView }, { - path: '/about', - name: 'about', - // route level code-splitting - // this generates a separate chunk (About.[hash].js) for this route - // which is lazy-loaded when the route is visited. - component: () => import('../views/AboutView.vue') + path: '/login', + name: 'login', + component: LoginView + }, + { + path: '/selectProfile', + name: "selectProfile", + component: SelectProfileView } ] }) diff --git a/src/stores/authStore.js b/src/stores/authStore.js new file mode 100644 index 0000000000000000000000000000000000000000..79696ca8029380771af493c2e45726ccd9347c17 --- /dev/null +++ b/src/stores/authStore.js @@ -0,0 +1,32 @@ +import { defineStore } from "pinia"; +export const useAuthStore = defineStore("auth", { + state: () => { + return { + token: "", + user: {}, + profile: {}, + }; + }, + persist: { + storage: localStorage + }, + getters: { + isLoggedIn() { + return this.token.length > 0 + } + }, + actions: { + setToken(token) { + this.token = token; + }, + setUser(user) { + this.user = user; + }, + logout() { + this.$reset(); + }, + setProfile(profile) { + this.profile = profile; + } + } +}); diff --git a/src/stores/counter.js b/src/stores/counter.js deleted file mode 100644 index b6757ba5723c5b89b35d011b9558d025bbcde402..0000000000000000000000000000000000000000 --- a/src/stores/counter.js +++ /dev/null @@ -1,12 +0,0 @@ -import { ref, computed } from 'vue' -import { defineStore } from 'pinia' - -export const useCounterStore = defineStore('counter', () => { - const count = ref(0) - const doubleCount = computed(() => count.value * 2) - function increment() { - count.value++ - } - - return { count, doubleCount, increment } -}) diff --git a/src/util/API.js b/src/util/API.js new file mode 100644 index 0000000000000000000000000000000000000000..b932e12ab318fd729555e56849553579cc4a83a0 --- /dev/null +++ b/src/util/API.js @@ -0,0 +1,106 @@ +import axios from "axios"; +import { useAuthStore } from "@/stores/authStore.js"; +import jwt_decode from "jwt-decode"; +import router from "@/router/index"; + +export const API = { + + /** + * API method to send a login request. + * If login succeeds, the logged in User and their token + * is saved to the Pinia AuthStore + * + * @param email email address of the user to log in as + * @param password password to log in with + * @returns a Result with whether the login attempt succeeded + */ + login: async (request) => { + const authStore = useAuthStore(); + let token; + + return axios.post( + `${import.meta.env.VITE_BACKEND_URL}/login`, + request, + ) + .then(async (response) => { + token = response.data; + const id = (jwt_decode(token)).id; + + return API.getAccount(id, token) + .then((user) => { + authStore.setUser(user); + authStore.setToken(token); + return; + }) + .catch(() => { + throw new Error(); + }); + }) + .catch(() => { + throw new Error(); + }); + }, + + + /** + * API method to get a account by their ID + * @param id ID number of the account to retrieve + * @returns A promise that resolves to a User if the API call succeeds, + * or is rejected if the API call fails + */ + getAccount: async (id, token) => { + return axios.get(`${import.meta.env.VITE_BACKEND_URL}/account/${id}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((response) => { + return response.data; + }) + .catch(() => { + throw new Error("Account not found or not accessible"); + }); + }, + + // Sends the user into the home page logged in as the profile they clicked on + selectProfile: async (id) => { + const authStore = useAuthStore() + return axios.get(`${import.meta.env.VITE_BACKEND_URL}/profile/${id}`, { + headers: { Authorization: `Bearer ${authStore.token}` }, + }) + .then((response) => { + authStore.setProfile(response.data) + router.push("/") + + }) + .catch(() => { + throw new Error("Profile not found or not accessible") + }) + + + }, + + // Sends the user into the "register profile" view + addProfile: async () => { + console.log("todo"); + }, + + // Returns all profiles to the logged in user + getProfiles: async () => { + const authStore = useAuthStore(); + if (!authStore.isLoggedIn) { + throw new Error(); + } + + + return axios.get(import.meta.env.VITE_BACKEND_URL + '/profile', { + headers: { Authorization: "Bearer " + authStore.token }, + }, + ) + .then(response => { + + console.log(response.data) + return response.data + }).catch(() => { + throw new Error(); + }); + } +} diff --git a/src/views/AboutView.vue b/src/views/AboutView.vue deleted file mode 100644 index 756ad2a17909837834858538422308120cf09dab..0000000000000000000000000000000000000000 --- a/src/views/AboutView.vue +++ /dev/null @@ -1,15 +0,0 @@ -<template> - <div class="about"> - <h1>This is an about page</h1> - </div> -</template> - -<style> -@media (min-width: 1024px) { - .about { - min-height: 100vh; - display: flex; - align-items: center; - } -} -</style> diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue new file mode 100644 index 0000000000000000000000000000000000000000..1a6a64bc4d6c95261ff93ac97f43c52c958cc4f4 --- /dev/null +++ b/src/views/LoginView.vue @@ -0,0 +1,114 @@ +<script> + import { API } from '@/util/API.js'; + import router from '@/router/index.js'; + + export default { + data() { + return { + welcomemsg: "Velkommen tilbake", + email: "", + password: "", + errormsg: "", + } + }, + methods: { + login() { + //todo: implement when API is up + API.login({email: this.email, password: this.password}).then(() => { + router.push("/selectProfile"); + }) + .catch(() => { + this.errormsg = "Kunne ikke logge inn! Sjekk brukernavn og passord, og prøv igjen"; + }); + } + } + } + +</script> + +<template> + <main> + <div class="login-container"> + <img id="logo" src="../components/icons/logo.png" alt="Logo"> + <h1>{{ welcomemsg }}</h1> + <form @submit.prevent="login"> + <div class="field-container"> + <label for="email">E-post</label> + <input id="email-input" name="email" type="text" v-model="email" /> + </div> + + <div class="field-container"> + <label for="password">Passord</label> + <input id="password-input" name="password" type="password" v-model="password" /> + </div> + + <p id="error-message">{{ errormsg }}</p> + <button @click="login" id="login-button">Logg inn</button> + </form> + + <p><RouterLink to="/newuser">Ny bruker</RouterLink> - <a href="#">Glemt passord?</a></p> + </div> + </main> +</template> + +<style> + +.login-container { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + + min-width: 300px; + margin-top: 40px; +} + +form { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +.field-container { + padding: 10px; + display: flex; + flex-direction: column; +} + +input { + height: 40px; + font-size: 16px; + padding-left: 10px; +} + +label { + font-size: 18px; +} + +#login-button { + background-color: #00663C; + color: #FFFFFF; + border-radius: 5px; + border-style: none; + width: 150px; + height: 40px; + font-size: 18px; + font-weight: bold; + margin: 20px; +} + +#logo { + width: 100px; + height: 100px; +} + +@media (min-width: 1024px) { + .login-container { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + } +} +</style> diff --git a/src/views/SelectProfileView.vue b/src/views/SelectProfileView.vue new file mode 100644 index 0000000000000000000000000000000000000000..6dc0b9fd642dc6d232a22a38ebb5f95725ea1503 --- /dev/null +++ b/src/views/SelectProfileView.vue @@ -0,0 +1,113 @@ +<script> + import { API } from '@/util/API.js'; + + export default { + data() { + return { + profiles: [] + } + }, + methods: { + // Sends the user into the home page logged in as the profile they clicked on + selectProfile(id) { + API.selectProfile(id); + }, + + // Sends the user into the "register profile" view + addProfile() { + API.addProfile(); + }, + + // Receives all profiles from this user + async getProfiles() { + await API.getProfiles() + .then(response => {this.profiles = response}) + .catch(() => new Error()); + } + }, + + mounted() { + this.getProfiles(); + } + } + +</script> + + +<template> + <div class="container"> + <h1>Hvem bruker appen?</h1> + + <div class="icons"> + <div v-for="profile in this.profiles" @click=selectProfile(profile.id) class="icon"> + + <img v-if="profile.profileImageUrl == ''" src="https://t4.ftcdn.net/jpg/02/15/84/43/360_F_215844325_ttX9YiIIyeaR7Ne6EaLLjMAmy4GvPC69.jpg" alt="profile image"> + <img v-else :src=profile.profileImageUrl alt="profile image"> + <p>{{profile.name}}</p> + </div> + + </div> + + <div class="add"> + <button @click="addProfile">+</button> + </div> + </div> +</template> + + +<style scoped> + + .container { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + + gap: 20px; + min-width: 296px; + margin-top: 40px; + } + + .icons { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 20px; + max-width: 550px; + + } + + .icon { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + font-size: 20px; + } + + .icon:hover { + background-color: #d5d5d5; + border-radius: 10%; + } + + img { + height: 130px; + width: 130px; + border-radius: 50%; + } + + + button { + border-radius: 50%; + border-style: none; + width: 50px; + height: 50px; + font-size: 50px; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 10px; + } + +</style> \ No newline at end of file diff --git a/src/views/__tests__/LoginView.spec.js b/src/views/__tests__/LoginView.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..64e4c4d57d34842cdf969823f594099879b081a0 --- /dev/null +++ b/src/views/__tests__/LoginView.spec.js @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' + +import { mount } from '@vue/test-utils' +import LoginView from '../LoginView.vue' + +describe('Login', () => { + it('renders properly', () => { + const wrapper = mount(LoginView) + expect(wrapper.text()).toContain('E-post') + }) + + it('login button exists', () => { + const wrapper = mount(LoginView) + wrapper.find('#login-button').exists() + }) +}) \ No newline at end of file diff --git a/src/views/__tests__/SelectProfileView.spec.js b/src/views/__tests__/SelectProfileView.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..eb411366d7832837edd1aec94a2c68f9d2e7fe18 --- /dev/null +++ b/src/views/__tests__/SelectProfileView.spec.js @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' + +import { mount } from '@vue/test-utils' +import SelectProfileView from '../SelectProfileView.vue' + +describe('Select profile', () => { + it('renders properly', () => { + const wrapper = mount(SelectProfileView) + expect(wrapper.text()).toContain('Hvem bruker appen?') + expect(wrapper.text()).toContain('+') + }) + + it('loads with one profile', () => { + const wrapper = mount(SelectProfileView, { + data() { + return { + profiles: [{ + id: -1, + name: "test", + profileImageUrl: "", + }] + } + } + }) + expect(wrapper.text()).toContain("test") + + }) +}) \ No newline at end of file