From f0b5682a25a590e6276ece1b5386b8ac01e93e4d Mon Sep 17 00:00:00 2001 From: Hubert Van De Walle Date: Thu, 23 Apr 2020 00:08:18 +0200 Subject: [PATCH 1/3] Add new route /user/me --- api/src/routing/LoginController.kt | 29 +++++++++++++++++++---------- api/src/services/UserService.kt | 12 +++++++++++- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/api/src/routing/LoginController.kt b/api/src/routing/LoginController.kt index cf626b5..78c7be2 100644 --- a/api/src/routing/LoginController.kt +++ b/api/src/routing/LoginController.kt @@ -2,8 +2,10 @@ package be.vandewalleh.routing import be.vandewalleh.auth.SimpleJWT import be.vandewalleh.auth.UsernamePasswordCredential +import be.vandewalleh.extensions.respondStatus import be.vandewalleh.services.UserService import io.ktor.application.* +import io.ktor.auth.* import io.ktor.http.* import io.ktor.request.* import io.ktor.response.* @@ -18,20 +20,27 @@ fun Routing.login(kodein: Kodein) { data class TokenResponse(val token: String) - route("/user/login"){ - post { - val credential = call.receive() + post("/user/login") { + val credential = call.receive() - val (email, password) = userService.getEmailAndPasswordFromUsername(credential.username) - ?: return@post call.respond(HttpStatusCode.Unauthorized) + val (email, password) = userService.getEmailAndPasswordFromUsername(credential.username) + ?: return@post call.respond(HttpStatusCode.Unauthorized) - if (!BCrypt.checkpw(credential.password, password)) { - return@post call.respond(HttpStatusCode.Unauthorized) - } + if (!BCrypt.checkpw(credential.password, password)) { + return@post call.respond(HttpStatusCode.Unauthorized) + } - return@post call.respond(TokenResponse(simpleJwt.sign(email))) + return@post call.respond(TokenResponse(simpleJwt.sign(email))) + } + + authenticate { + get("/user/me") { + // retrieve email from token + val email = call.principal()!!.name + val info = userService.getUserInfo(email) + if (info != null) call.respond(mapOf("user" to info)) + else call.respondStatus(HttpStatusCode.Unauthorized) } } - } \ No newline at end of file diff --git a/api/src/services/UserService.kt b/api/src/services/UserService.kt index f7020ac..75a6063 100644 --- a/api/src/services/UserService.kt +++ b/api/src/services/UserService.kt @@ -48,6 +48,15 @@ class UserService(override val kodein: Kodein) : KodeinAware { .firstOrNull() != null } + fun getUserInfo(email: String): UserInfoDto? { + return db.from(Users) + .select(Users.email, Users.username) + .where { Users.email eq email } + .limit(0, 1) + .map { UserInfoDto(it[Users.username]!!, it[Users.email]!!) } + .firstOrNull() + } + /** * create a new user * password should already be hashed @@ -85,4 +94,5 @@ class UserService(override val kodein: Kodein) : KodeinAware { } } -data class UserDto(val username: String, val email: String, val password: String) \ No newline at end of file +data class UserDto(val username: String, val email: String, val password: String) +data class UserInfoDto(val username: String, val email: String) \ No newline at end of file From 0096fb0a00d2e51e543c564e1e315179bec5fa7a Mon Sep 17 00:00:00 2001 From: Hubert Van De Walle Date: Thu, 23 Apr 2020 00:09:37 +0200 Subject: [PATCH 2/3] Add nuxt auth module --- frontend/api/api.js | 28 --------- frontend/nuxt.config.js | 29 ++++++++- frontend/package.json | 4 +- frontend/plugins/axios.js | 7 +++ frontend/services/LocalStorageService.js | 13 ---- frontend/services/LoginService.js | 19 ------ frontend/services/RegisterService.js | 20 ------ frontend/store/index.js | 26 +------- frontend/yarn.lock | 77 ++++++++++++++++++------ 9 files changed, 98 insertions(+), 125 deletions(-) delete mode 100644 frontend/api/api.js create mode 100644 frontend/plugins/axios.js delete mode 100644 frontend/services/LocalStorageService.js delete mode 100644 frontend/services/LoginService.js delete mode 100644 frontend/services/RegisterService.js diff --git a/frontend/api/api.js b/frontend/api/api.js deleted file mode 100644 index e078ebf..0000000 --- a/frontend/api/api.js +++ /dev/null @@ -1,28 +0,0 @@ -import axios from 'axios' - -import {mapState} from "vuex"; - -const state = mapState(['token']) - -const apiClient = axios.create({ - baseURL: `http://localhost:5000`, - withCredentials: false, - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - } -}) - -axios.interceptors.request.use( - config => { - const token = state.token; - if (token) { - config.headers['Authorization'] = 'Bearer ' + token; - } - return config; - }, - error => { - Promise.reject(error) - }); - -export default apiClient diff --git a/frontend/nuxt.config.js b/frontend/nuxt.config.js index 353180c..08cd8d7 100644 --- a/frontend/nuxt.config.js +++ b/frontend/nuxt.config.js @@ -28,7 +28,7 @@ export default { /* ** Plugins to load before mounting the App */ - plugins: [], + plugins: ['~/plugins/axios'], /* ** Nuxt.js dev-modules */ @@ -43,13 +43,38 @@ export default { // Doc: https://axios.nuxtjs.org/usage '@nuxtjs/axios', // Doc: https://github.com/nuxt-community/dotenv-module - '@nuxtjs/dotenv' + '@nuxtjs/dotenv', + '@nuxtjs/auth' ], /* ** Axios module configuration ** See https://axios.nuxtjs.org/options */ axios: {}, + + auth: { + redirect: { + login: '/account', + logout: '/', + home: '/', + }, + watchLoggedIn: true, + //cookie: true, + strategies: { + local: { + endpoints: { + login: {url: '/user/login', method: 'post', propertyName: 'token'}, + user: {url: '/user/me', method: 'get', propertyName: 'user'}, + }, + autoFetchUser: true + } + } + }, + + router: { + middleware: ['auth'] + }, + /* ** vuetify module configuration ** https://github.com/nuxt-community/vuetify-module diff --git a/frontend/package.json b/frontend/package.json index d41c2c2..020eb65 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,10 +8,10 @@ "dev": "nuxt", "build": "nuxt build", "start": "nuxt start", - "generate": "nuxt generate", - "lint": "eslint --ext .js,.vue --ignore-path .gitignore ." + "generate": "nuxt generate" }, "dependencies": { + "@nuxtjs/auth": "^4.9.1", "@nuxtjs/axios": "^5.3.6", "@nuxtjs/dotenv": "^1.4.0", "masonry-layout": "^4.2.2", diff --git a/frontend/plugins/axios.js b/frontend/plugins/axios.js new file mode 100644 index 0000000..770eb96 --- /dev/null +++ b/frontend/plugins/axios.js @@ -0,0 +1,7 @@ +export default function ({ $axios }) { + $axios.onRequest(config => { + console.log('Making request to ' + config.url) + }) + + $axios.setBaseURL('http://localhost:8081') +} diff --git a/frontend/services/LocalStorageService.js b/frontend/services/LocalStorageService.js deleted file mode 100644 index 4593154..0000000 --- a/frontend/services/LocalStorageService.js +++ /dev/null @@ -1,13 +0,0 @@ -export function setToken(token) { - if (process.browser && token) { - localStorage.setItem('token', token) - } -} - -export function clearToken() { - if (process.browser) { - localStorage.removeItem('token') - } -} - -export default {setToken, clearToken} diff --git a/frontend/services/LoginService.js b/frontend/services/LoginService.js deleted file mode 100644 index 4d3b84a..0000000 --- a/frontend/services/LoginService.js +++ /dev/null @@ -1,19 +0,0 @@ -import apiClient from '@/api' - -export default { - async login({username, password}) { - try { - const {data} = await apiClient.post('/user/signin', { - username, - password - }) - return {token: data["access_token"]} - } catch (e) { - if (e.response && e.response.status === 401) - return Promise.reject({invalid: true}) - else - return Promise.reject({error: true}) - } - - } -} diff --git a/frontend/services/RegisterService.js b/frontend/services/RegisterService.js deleted file mode 100644 index 637d57c..0000000 --- a/frontend/services/RegisterService.js +++ /dev/null @@ -1,20 +0,0 @@ -import apiClient from '@/api' - -export default { - async register({username, email, password}) { - try { - await apiClient.post('/user', { - username, - email, - password - }) - return {success: true} - } catch (e) { - if (e.response && e.response.status === 409) - return Promise.reject({exists: true}) - else - return Promise.reject({error: true}) - } - - } -} diff --git a/frontend/store/index.js b/frontend/store/index.js index cafce0f..6358304 100644 --- a/frontend/store/index.js +++ b/frontend/store/index.js @@ -1,27 +1,7 @@ -import {clearToken, setToken} from "@/services/LocalStorageService"; +export const state = () => ({}) -export const state = () => ({ - token: '' -}) - -export const mutations = { - setToken(state, token) { - state.token = token - setToken(token) - }, - clearToken(state) { - state.token = null - clearToken() - }, - initToken(state) { - state.token = localStorage.getItem('token') - } -} +export const mutations = {} export const actions = {} -export const getters = { - isLoggedIn(state) { - return state.token !== null && state.token !== '' - } -} +export const getters = {} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index c8da85c..239f982 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1048,6 +1048,20 @@ webpack-node-externals "^1.7.2" webpackbar "^4.0.0" +"@nuxtjs/auth@^4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@nuxtjs/auth/-/auth-4.9.1.tgz#8827e4d23bf901711423434ad4a7073a8fc51603" + integrity sha512-h5VZanq2+P47jq3t0EnsZv800cg/ufOPC6JqvcyeDFJM99p58jHSODAjDuePo3PrZxd8hovMk7zusU5lOHgjvQ== + dependencies: + "@nuxtjs/axios" "^5.9.5" + body-parser "^1.19.0" + consola "^2.11.3" + cookie "^0.4.0" + is-https "^1.0.0" + js-cookie "^2.2.1" + lodash "^4.17.15" + nanoid "^2.1.11" + "@nuxtjs/axios@^5.3.6": version "5.9.7" resolved "https://registry.yarnpkg.com/@nuxtjs/axios/-/axios-5.9.7.tgz#ec78b72dbcb70fceee7724b7f24e0cb4d924440c" @@ -1059,6 +1073,17 @@ consola "^2.11.3" defu "^1.0.0" +"@nuxtjs/axios@^5.9.5": + version "5.10.0" + resolved "https://registry.yarnpkg.com/@nuxtjs/axios/-/axios-5.10.0.tgz#3232980d781a208c672cd09e774e25e77208f0cb" + integrity sha512-6zAvjQ/37qMzyk0OmgFI2iLAOJ6ADdm29mfRlmOKR5iR1ip3Mxzhm02O8WLcET3UrE74WuIHdli/WK/5e35bXw== + dependencies: + "@nuxtjs/proxy" "^1.3.3" + axios "^0.19.2" + axios-retry "^3.1.6" + consola "^2.11.3" + defu "^2.0.2" + "@nuxtjs/dotenv@^1.4.0": version "1.4.1" resolved "https://registry.yarnpkg.com/@nuxtjs/dotenv/-/dotenv-1.4.1.tgz#dd5abb98e22cc7ae27139d3aa606151034293128" @@ -1581,6 +1606,13 @@ axios-retry@^3.1.2: dependencies: is-retry-allowed "^1.1.0" +axios-retry@^3.1.6: + version "3.1.6" + resolved "https://registry.yarnpkg.com/axios-retry/-/axios-retry-3.1.6.tgz#566d591b4fbcbcf90728b639a7642eb5cc3785c9" + integrity sha512-pqOgBcpDtKU2YIBmHaHM8XnvzuOyRBxcvnD8+25uT0JcUEF0M1jq7Rpd7dTP27P8hQTynr/GNRuhEXZBLBffOw== + dependencies: + is-retry-allowed "^1.1.0" + axios@^0.19.2: version "0.19.2" resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27" @@ -1676,7 +1708,7 @@ bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== -body-parser@1.19.0: +body-parser@1.19.0, body-parser@^1.19.0: version "1.19.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== @@ -2285,6 +2317,11 @@ cookie@^0.3.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s= +cookie@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" + integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== + copy-concurrently@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" @@ -2669,6 +2706,11 @@ defu@^1.0.0: resolved "https://registry.yarnpkg.com/defu/-/defu-1.0.0.tgz#43acb09dfcf81866fa3b0fc047ece18e5c30df71" integrity sha512-1Y1KRFxiiq+LYsZ3iP7xYSR8bHfmHFOUpDunZCN1ld1fGfDJWJIvkUBtjl3apnBwPuJtL/H7cwwlLYX8xPkraQ== +defu@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/defu/-/defu-2.0.2.tgz#9a3d4c1330d60c0ed4812e51864b948c51f7ad45" + integrity sha512-E5dO3ji0TmVcZaB/2G6Ovu5zNHbWkgCU7v+EoE/Jj1Lbwv1BB6hNNKLkio2ZLI3/e3avlO634QUhQl4iCpm3Bg== + depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -3704,13 +3746,6 @@ ignore@^5.1.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.4.tgz#84b7b3dbe64552b6ef0eca99f6743dbec6d97adf" integrity sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A== -imagesloaded@4.1.4: - version "4.1.4" - resolved "https://registry.yarnpkg.com/imagesloaded/-/imagesloaded-4.1.4.tgz#1376efcd162bb768c34c3727ac89cc04051f3cc7" - integrity sha512-ltiBVcYpc/TYTF5nolkMNsnREHW+ICvfQ3Yla2Sgr71YFwQ86bDwV9hgpFhFtrGPuwEx5+LqOHIrdXBdoWwwsA== - dependencies: - ev-emitter "^1.0.0" - import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -3951,6 +3986,11 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-https@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-https/-/is-https-1.0.0.tgz#9c1dde000dc7e7288edb983bef379e498e7cb1bf" + integrity sha512-1adLLwZT9XEXjzhQhZxd75uxf0l+xI9uTSFaZeSESjL3E1eXSPpO+u5RcgqtzeZ1KCaNvtEwZSTO2P4U5erVqQ== + is-nan@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.0.tgz#85d1f5482f7051c2019f5673ccebdb06f3b0db03" @@ -4068,6 +4108,11 @@ jest-worker@^25.1.0: merge-stream "^2.0.0" supports-color "^7.0.0" +js-cookie@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" + integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -4581,6 +4626,11 @@ nan@^2.12.1: resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== +nanoid@^2.1.11: + version "2.1.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280" + integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -7093,15 +7143,6 @@ vue-loader@^15.9.1: vue-hot-reload-api "^2.3.0" vue-style-loader "^4.1.0" -vue-masonry@^0.11.8: - version "0.11.8" - resolved "https://registry.yarnpkg.com/vue-masonry/-/vue-masonry-0.11.8.tgz#fc2dd458d13b557eebc68d70506af76aa6d1428e" - integrity sha512-O+T+3zUghbKpjc+5aubXr8Kg1h9P334+Or9euYyXsQYa3mtScUqZFI6A16BijR9v4hYdtKksuPzU0mQplUvhDA== - dependencies: - imagesloaded "4.1.4" - masonry-layout "^4.2.2" - vue "^2.0.0" - vue-meta@^2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/vue-meta/-/vue-meta-2.3.3.tgz#2a097f62817204b0da78be4d62aee0cb566eaee0" @@ -7154,7 +7195,7 @@ vue-template-es2015-compiler@^1.9.0: resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825" integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw== -vue@^2.0.0, vue@^2.6.11: +vue@^2.6.11: version "2.6.11" resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.11.tgz#76594d877d4b12234406e84e35275c6d514125c5" integrity sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ== From 2dc79853346e40a6cebed559cfb752764c5145e8 Mon Sep 17 00:00:00 2001 From: Hubert Van De Walle Date: Thu, 23 Apr 2020 00:11:26 +0200 Subject: [PATCH 3/3] Implement auth & register --- frontend/components/LoginForm.vue | 19 +++++++++++----- frontend/components/RegisterForm.vue | 21 ++++++++++-------- frontend/layouts/default.vue | 9 ++++---- frontend/layouts/home.vue | 33 ---------------------------- frontend/pages/account.vue | 3 +++ frontend/pages/index.vue | 8 +++---- 6 files changed, 36 insertions(+), 57 deletions(-) delete mode 100644 frontend/layouts/home.vue diff --git a/frontend/components/LoginForm.vue b/frontend/components/LoginForm.vue index dd0b366..0be81d9 100644 --- a/frontend/components/LoginForm.vue +++ b/frontend/components/LoginForm.vue @@ -7,7 +7,7 @@ lazy-validation > Login @@ -42,17 +42,24 @@ export default { name: "LoginForm", methods: { - submit() { + async userLogin() { + try { + const response = await this.$auth.loginWith('local', {data: this.form}) + } catch (err) { + console.log(err) + } } }, data: () => ({ valid: false, - username: '', + form: { + username: '', + password: '' + }, usernameRules: [ v => !!v || 'Name is required', ], - password: '', passwordRules: [ v => !!v || 'Password is required', ] diff --git a/frontend/components/RegisterForm.vue b/frontend/components/RegisterForm.vue index 667423e..9051585 100644 --- a/frontend/components/RegisterForm.vue +++ b/frontend/components/RegisterForm.vue @@ -7,7 +7,7 @@ lazy-validation > Register @@ -60,22 +60,25 @@ export default { name: "RegisterForm", methods: { - submit() { - + async registerUser() { + const data = await this.$axios.post('/user', this.form) + console.log(data) } }, data: () => ({ valid: false, - username: '', + form: { + username: '', + email: '', + password: '' + }, usernameRules: [ v => !!v || 'Name is required', ], - email: '', emailRules: [ v => !!v || 'Email is required', v => /.+@.+/.test(v) || 'E-mail must be valid', ], - password: '', passwordRules: [ v => !!v || 'Password is required', v => v.length >= 6 || 'Password must be longer than 6 characters', diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index f0b3293..20684f6 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -6,10 +6,11 @@ color="primary" dark > - Notes + Simple Notes My notes - Account + Welcome {{this.$store.state.auth.user.username}} + Account @@ -20,9 +21,7 @@ diff --git a/frontend/pages/account.vue b/frontend/pages/account.vue index 01d08b4..0908186 100644 --- a/frontend/pages/account.vue +++ b/frontend/pages/account.vue @@ -44,6 +44,9 @@ export default { name: "centered", layout: "centered", + options: { + auth: 'guest', + }, data: () => ({ tab: 0, tabs: ["Login", "Register"] diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index c1e4b23..30900d0 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -6,10 +6,10 @@ export default { title: 'Home', - layout: 'home', - data: () => ({ - - }), + options: { + auth: false + }, + data: () => ({}), head: () => ({ title: "Home" })