diff --git a/package-lock.json b/package-lock.json index ee2c710acace40e8b5f781384980fabf0bb108ae..cc152e8216a58d74572e8c18ccc8c5359ccd71d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "smartmat-frontend", "version": "0.0.0", "dependencies": { + "@vuelidate/core": "^2.0.2", + "@vuelidate/validators": "^2.0.2", "element-plus": "^2.3.3", "pinia": "^2.0.32", "vue": "^3.2.47", @@ -1438,6 +1440,90 @@ } } }, + "node_modules/@vuelidate/core": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@vuelidate/core/-/core-2.0.2.tgz", + "integrity": "sha512-aG1OZWv6xVws3ljyKy/pyxq1rdZZ2ryj+FEREcC9d4GP4qOvNHHZUl/NQxa0Bck3Ooc0RfXU8vwCA9piRoWy6w==", + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^2.0.0 || >=3.0.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vuelidate/core/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vuelidate/validators": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@vuelidate/validators/-/validators-2.0.2.tgz", + "integrity": "sha512-6y6QLoK567XVmaLP3Paf1vkg6K2zO6xax3yTyczy1RnJ4PsLDLLGzP1PFzSpwb16aw4CKduBgI63HvIuctJhQg==", + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^2.0.0 || >=3.0.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vuelidate/validators/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@vueuse/core": { "version": "9.13.0", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.13.0.tgz", diff --git a/package.json b/package.json index 7fba60885018228333a821447061540dfd892651..789b6e62a592b64662e0b6e0d4b8fc6f03732ee4 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "format:check": "prettier --check src/ cypress" }, "dependencies": { + "@vuelidate/core": "^2.0.2", + "@vuelidate/validators": "^2.0.2", "element-plus": "^2.3.3", "pinia": "^2.0.32", "vue": "^3.2.47", diff --git a/src/App.vue b/src/App.vue index 68dbc0376c0a46a6bcb6d194f572efafb550fca0..2034b8eabd5a5f3430dbe8dd9e6cae947f2facfc 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,10 +1,12 @@ <script setup lang="ts"> +import { computed, onMounted, ref } from "vue"; +import { Expand } from "@element-plus/icons-vue"; +import { useToggle } from "@vueuse/core"; import { RouterView } from "vue-router"; +import router from "@/router"; + import SideNavBar from "@/components/SideNavBar.vue"; import TopNavBar from "@/components/TopNavBar.vue"; -import { computed, onMounted, ref } from "vue"; -import { useToggle } from "@vueuse/core"; -import { Expand } from "@element-plus/icons-vue"; const windowSize = ref(window.innerWidth); @@ -19,6 +21,8 @@ const collapsed = computed(() => { }); const [drawer, drawerToggle] = useToggle(); + +const isFullScreen = computed(() => router.currentRoute.value.meta?.fullScreen); </script> <template> @@ -38,8 +42,8 @@ const [drawer, drawerToggle] = useToggle(); </el-menu-item> </el-menu> </div> - <el-container style="height: 0"> - <el-aside width="300px" v-if="!collapsed"> + <el-container> + <el-aside width="300px" v-if="!collapsed && !isFullScreen"> <SideNavBar class="sidenav" /> </el-aside> <el-drawer v-model="drawer" direction="ltr" size="306px"> diff --git a/src/assets/main.css b/src/assets/main.css index 10a8bb4d013c264fc68aac1b04bffdbadd9cb439..ab5ff62a0a48937d71342491adf8c1ca83955037 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -23,6 +23,6 @@ main { } @media only screen and (max-width: 768px) { main { - padding: 1rem !important; + padding: 0.5rem !important; } } diff --git a/src/components/RegisterComponent.vue b/src/components/RegisterComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..fa65ee8813e2e529c36439077c9bd8afc2a67290 --- /dev/null +++ b/src/components/RegisterComponent.vue @@ -0,0 +1,131 @@ +<template> + <el-card class="container"> + <h2 class="my-3">Registrer deg</h2> + + <el-form + ref="ruleFormRef" + label-position="top" + :model="newUser" + :rules="validationRules" + status-icon + > + <el-form-item label="Email" prop="email"> + <el-input placeholder="Email" type="text" v-model="email" size="large" /> + </el-form-item> + + <el-form-item label="Fornavn" prop="firstName"> + <el-input placeholder="fornavn" type="text" v-model="firstName" size="large" /> + </el-form-item> + + <el-form-item label="Passord" prop="password"> + <el-input type="password" v-model="password" placeholder="Password" size="large" /> + </el-form-item> + + <el-form-item label="Gjenta passord" prop="passwordConfirm"> + <el-input type="password" v-model="passwordConfirm" placeholder="Password" size="large" /> + </el-form-item> + + <el-button ref="submitButton" type="primary" size="large" @click="submit" + >Registrer deg + </el-button> + </el-form> + <el-alert + type="error" + show-icon + :title="errorMessage" + v-if="errorMessage" + style="margin-top: 1rem" + /> + </el-card> +</template> +<script setup lang="ts"> +import type { CreateUser } from "@/services"; +import { computed, ref } from "vue"; +import type { FormInstance } from "element-plus"; + +const ruleFormRef = ref<FormInstance>(); +const props = defineProps<{ + firstName: string; + password: string; + email: string; + errorMessage: string; +}>(); + +const emit = defineEmits<{ + "update:firstName": (firstName: string) => void; + "update:password": (password: string) => void; + "update:email": (email: string) => void; + submit: () => void; +}>(); + +const passwordConfirm = ref<string>(""); + +const validationRules = ref({ + firstName: [ + { required: true, message: "Fornavn er påkrevd", trigger: "blur" }, + { min: 2, message: "Fornavn må være minst 2 tegn", trigger: "blur" }, + ], + email: [ + { required: true, message: "Email er påkrevd", trigger: "blur" }, + { type: "email", message: "Email må være en gyldig email", trigger: "blur" }, + ], + password: [ + { required: true, message: "Passord er påkrevd", trigger: "blur" }, + { min: 8, message: "Passord må være minst 8 tegn", trigger: "blur" }, + ], + passwordConfirm: [ + { required: true, message: "Gjenta passordet", trigger: "blur" }, + { min: 8, message: "Passord må være minst 8 tegn", trigger: "blur" }, + { + validator: (rule, value, callback) => { + if (value !== password.value) { + callback(new Error("Passordene må være like")); + } else { + callback(); + } + }, + trigger: "blur", + }, + ], +}); + +const firstName = computed({ + get: () => props.firstName, + set: (value: string) => emit("update:firstName", value), +}); + +const password = computed({ + get: () => props.password, + set: (value: string) => emit("update:password", value), +}); + +const email = computed({ + get: () => props.email, + set: (value: string) => emit("update:email", value), +}); + +const newUser = computed(() => { + return { + firstName: props.firstName, + password: props.password, + email: props.email, + passwordConfirm: passwordConfirm.value, + } as CreateUser; +}); + +function submit() { + if (!ruleFormRef.value) return; + ruleFormRef.value.validate((valid) => { + if (valid) { + emit("submit"); + } + }); +} +</script> +<style scoped> +.container { + width: 90%; + margin: 10vh auto; + max-width: 500px; +} +</style> diff --git a/src/components/TopNavBar.vue b/src/components/TopNavBar.vue index 01841189da3b2bcf42d8988ff1f25ec176075491..54e59d27eb6a7ec0d33dcdc6c3f36105d99877d8 100644 --- a/src/components/TopNavBar.vue +++ b/src/components/TopNavBar.vue @@ -8,13 +8,13 @@ > <el-menu-item index="0">LOGO</el-menu-item> <div class="flex-grow" /> - <el-menu-item index="1"> + <el-menu-item index="1" v-if="sessionStore.isAuthenticated"> <el-icon> <User /> </el-icon> <span>Profil</span> </el-menu-item> - <el-button class="menu-item-button"> + <el-button class="menu-item-button" v-if="sessionStore.isAuthenticated" @click="logOut"> <el-icon> <TurnOff /> </el-icon> @@ -26,11 +26,18 @@ <script lang="ts" setup> import { ref } from "vue"; import { TurnOff, User } from "@element-plus/icons-vue"; +import { useSessionStore } from "@/stores/session"; const activeIndex = ref("1"); const handleSelect = (key: string, keyPath: string[]) => { console.log(key, keyPath); }; + +const sessionStore = useSessionStore(); + +function logOut() { + sessionStore.logOut(); +} </script> <style scoped> diff --git a/src/router/index.ts b/src/router/index.ts index a62fe31f401c78e9503694477fc5e13eddea16d1..86f211f898fa9725aa56dd2692b5a9834d0e6983 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,7 +1,10 @@ import { createRouter, createWebHistory } from "vue-router"; import HomeView from "../views/HomeView.vue"; import NotFoundView from "../views/NotFoundView.vue"; +import { useSessionStore } from "@/stores/session"; +import { AccountApi } from "@/services/index"; +let startup = true; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ @@ -9,23 +12,68 @@ const router = createRouter({ path: "/", name: "home", component: HomeView, - }, - { - path: "/:pathMatch(.*)*", - name: "not-found", - component: NotFoundView, + meta: { + requiresAuth: true, + }, }, { path: "/shopping-list", name: "shopping-list", - component: () => import("../views/ShoppingListView.vue"), + component: () => import("@/views/ShoppingListView.vue"), + meta: { + requiresAuth: true, + }, }, { path: "/inventory", name: "inventory", component: () => import("@/views/InventoryView.vue"), + meta: { + requiresAuth: true, + }, + }, + { + path: "/login", + name: "login", + component: () => import("@/views/LoginView.vue"), + meta: { + fullScreen: true, + }, + }, + { + path: "/register", + name: "register", + component: () => import("@/views/RegisterView.vue"), + meta: { + fullScreen: true, + }, + }, + { + path: "/:pathMatch(.*)*", + name: "not-found", + component: NotFoundView, }, ], }); +router.beforeEach((to, from, next) => { + const sessionStore = useSessionStore(); + /* + const accountApi = new AccountApi(); + if (startup) { + accountApi.getUserById().then((data) => { + if (data.status == 200) { + sessionStore.authenticate(data.data); + } + }); + startup = false; + } + */ + if (to.meta.requiresAuth && !sessionStore.isAuthenticated) { + next({ name: "login" }); + } else { + next(); + } +}); + export default router; diff --git a/src/services/models/create-user.ts b/src/services/models/create-user.ts index f4a42fe170ad473aa2012d9af5c6fd59f64369c0..010e4e90ae932684e2bb3fa3cf01f1d8472b5386 100644 --- a/src/services/models/create-user.ts +++ b/src/services/models/create-user.ts @@ -29,6 +29,8 @@ export interface CreateUser { * @memberof CreateUser */ password?: string; + + passwordConfirm?: string; /** * * @type {string} diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue new file mode 100644 index 0000000000000000000000000000000000000000..5fcd9fccebd34ff6b592fbcd610a80de519af770 --- /dev/null +++ b/src/views/LoginView.vue @@ -0,0 +1,116 @@ +<template> + <el-card class="container"> + <h2>Log in</h2> + + <el-form ref="form" label-position="top" :model="data" class="login-form"> + <el-form-item label="Email" prop="email"> + <el-input + placeholder="Email" + type="text" + v-model="data.email" + size="large" + @input="errorMessage = ''" + /> + </el-form-item> + + <el-form-item label="Password" prop="password"> + <el-input + type="password" + v-model="data.password" + @input="errorMessage = ''" + placeholder="Password" + size="large" + /> + </el-form-item> + + <p class="no-account"> + Har du ikke konto? + <el-link type="primary" @click="router.push({ name: 'register' })">Registrer deg! </el-link> + </p> + + <el-button ref="submitButton" type="primary" size="large" @click="signIn" + >Logg inn + </el-button> + <el-alert + type="error" + v-if="errorMessage" + show-icon + closable + style="margin-top: 1rem" + :title="errorMessage" + > + </el-alert> + </el-form> + </el-card> +</template> + +<script setup lang="ts"> +import { reactive, ref } from "vue"; + +import type { LoginUser } from "@/services/index"; +import { AccountApi, HouseholdApi } from "@/services/index"; +import { useSessionStore } from "@/stores/session"; +import { showError } from "@/utils/error-utils"; +import router from "@/router"; +import { useHouseholdStore } from "@/stores/household"; + +// Define APIs +const accountApi = new AccountApi(); +const householdApi = new HouseholdApi(); + +// Define stores +const sessionStore = useSessionStore(); +const householdStore = useHouseholdStore(); + +// Define refs +const submitButton = ref<HTMLElement | null>(null); + +const data: LoginUser = reactive({ + email: "", + password: "", +}); + +const errorMessage = ref<string>(""); + +// Define callbacks +function signIn() { + accountApi + .loginUser(data) + .then((response) => response.data) + .then((data) => { + sessionStore.authenticate(data); + + // Get household the first household + householdApi + .getHouseholds(data.id!) + .then((response) => response.data) + .then((households) => { + householdStore.setHousehold(households[0]); + router.push({ name: "inventory" }); + }); + }) + .catch((error) => { + if (error.response.status === 401) { + errorMessage.value = "Feil brukernavn eller passord"; + } else { + errorMessage.value = "En uventet feil oppstod"; + } + }); +} +</script> + +<style scoped> +.container { + width: 90%; + margin: 10vh auto; + max-width: 500px; +} + +.login-form { + flex-direction: column; +} + +.no-account { + text-align: end; +} +</style> diff --git a/src/views/RegisterView.vue b/src/views/RegisterView.vue new file mode 100644 index 0000000000000000000000000000000000000000..963d3a70d0f5e765340921f4f1104412a6f56cdf --- /dev/null +++ b/src/views/RegisterView.vue @@ -0,0 +1,49 @@ +<template> + <RegisterComponent + v-model:email="user.email" + v-model:first-name="user.firstName" + v-model:password="user.password" + @submit="submit" + :error-message="errorMessage" + > + </RegisterComponent> +</template> +<script setup lang="ts"> +import { reactive, ref } from "vue"; +import RegisterComponent from "@/components/RegisterComponent.vue"; +import type { CreateUser } from "@/services/index"; +import { AccountApi } from "@/services/index"; +import { useSessionStore } from "@/stores/session"; +import router from "@/router"; +import { showError } from "@/utils/error-utils"; + +const user = reactive({ + email: "", + password: "", + firstName: "", +} as CreateUser); + +const accountApi = new AccountApi(); +const sessionStore = useSessionStore(); +const errorMessage = ref<string>(""); + +function submit() { + console.log("submitting"); + accountApi + .createUser(user) + .then((data) => { + sessionStore.authenticate(data.data); + router.push({ name: "home" }); + }) + .catch((error) => { + if (error.response.status === 409) { + errorMessage.value = "En bruker med denne eposten eksisterer allerede"; + } else { + errorMessage.value = "En uventet feil oppstod"; + } + setTimeout(() => { + errorMessage.value = ""; + }, 5000); + }); +} +</script>