diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2133a86..9b8a574 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,10 +13,12 @@ "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/vue-fontawesome": "^3.0.8", + "@vueuse/core": "^12.8.2", "chart.js": "^4.4.8", "chartjs-adapter-date-fns": "^3.0.0", "countries-list": "^3.1.1", "lodash": "^4.17.21", + "mitt": "^3.0.1", "ol": "^10.4.0", "vue": "^3.5.13", "vue-chartjs": "^5.3.2", @@ -1767,6 +1769,12 @@ "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.24.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz", @@ -2319,6 +2327,42 @@ } } }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -3937,7 +3981,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, "license": "MIT" }, "node_modules/mrmime": { diff --git a/frontend/package.json b/frontend/package.json index 3c8a7f9..f91983c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,10 +18,12 @@ "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/vue-fontawesome": "^3.0.8", + "@vueuse/core": "^12.8.2", "chart.js": "^4.4.8", "chartjs-adapter-date-fns": "^3.0.0", "countries-list": "^3.1.1", "lodash": "^4.17.21", + "mitt": "^3.0.1", "ol": "^10.4.0", "vue": "^3.5.13", "vue-chartjs": "^5.3.2", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7002770..be63baf 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,24 +1,169 @@ <template> - <!-- - <header> - <div class="wrapper"> - <nav> - <RouterLink to="/">Home</RouterLink> - </nav> - </div> - </header> - --> + <div class="app-container"> + <header v-if="user"> + <div class="wrapper"> + <nav> + <li class="main"> + <RouterLink to="/"> + <font-awesome-icon icon="map-marker-alt" /> GPSTracker + </RouterLink> + </li> - <RouterView /> + <div class="spacer" /> + + <li class="right"> + <RouterLink to="/logout"> + <font-awesome-icon icon="sign-out-alt" /> + <span class="logout-text">Logout</span> + </RouterLink> + </li> + </nav> + </div> + </header> + + <div class="body"> + <Loading v-if="loading" /> + <RouterView /> + </div> + + <Messages /> + </div> </template> <script lang="ts"> import { RouterLink, RouterView } from 'vue-router' +import { type Optional } from './models/Types'; +import Api from './mixins/Api.vue'; +import Loading from './elements/Loading.vue'; +import Messages from './components/Messages.vue' +import User from './models/User'; + export default { + mixins: [Api], components: { + Loading, + Messages, RouterLink, RouterView, }, + + data() { + return { + loading: false, + user: null as Optional<User>, + } + }, + + async mounted() { + this.loading = true + + try { + const auth = await this.fetchUser() + const currentRedirect = this.$route.query.redirect + this.user = auth?.user + + if (auth) { + if (currentRedirect?.length) { + this.$router.push(currentRedirect as string) + } + } else { + let redirect = '/login' + if (currentRedirect?.length && currentRedirect !== '/login') { + redirect += `?redirect=${currentRedirect}` + } else if (this.$route.path !== '/login' && this.$route.path !== '/logout') { + redirect += `?redirect=${this.$route.path}${this.$route.hash}` + } else { + redirect += '?redirect=/' + } + + this.$router.push(redirect) + } + } finally { + this.loading = false + } + }, } </script> + +<style lang="scss"> +@use "@/styles/common.scss" as *; +</style> + +<style lang="scss" scoped> +@use "@/styles/common.scss" as *; + +$header-height: 3rem; + +.app-container { + width: 100%; + height: 100vh; + display: flex; + flex-direction: column; + background: var(--color-background); + + header { + width: 100%; + height: $header-height; + margin-bottom: 0.2rem; + padding: 0.5rem 0; + box-shadow: 0 0 0.5rem 0 rgba(0, 0, 0, 0.5); + + nav { + display: flex; + align-items: center; + + li { + display: inline-block; + list-style: none; + border-radius: 0.25rem; + + &.main { + font-size: 1.25rem; + margin-left: 0.5rem; + + :deep(a) { + &:hover { + background: none; + font-size: 1.5rem; + } + } + + &:hover { + margin-top: -0.25rem; + } + } + + &:not(.main) { + :deep(a) { + color: var(--color-text); + text-decoration: none; + } + } + + &.right { + margin-right: 0.5rem; + } + + .logout-text { + @include mobile { + display: none; + } + } + } + + .spacer { + flex: 1; + } + } + } + + .body { + width: 100%; + height: calc(100% - #{$header-height}); + flex: 1; + overflow-y: auto; + position: relative; + } +} +</style> diff --git a/frontend/src/assets/base.css b/frontend/src/assets/base.css index 0c7edbc..9edcbe3 100644 --- a/frontend/src/assets/base.css +++ b/frontend/src/assets/base.css @@ -60,6 +60,10 @@ --vt-c-lime-fg-dark: #27ae60; --vt-c-lime-bg-light: #5cb85c; --vt-c-lime-bg-dark: #449d44; + --vt-c-gray-fg-light: #95a5a6; + --vt-c-gray-fg-dark: #7f8c8d; + --vt-c-gray-bg-light: #d9edf7; + --vt-c-gray-bg-dark: #bce8f1; } /* semantic color variables for this project */ @@ -74,8 +78,14 @@ --color-heading: var(--vt-c-text-light-1); --color-text: var(--vt-c-text-light-1); --color-accent: var(--vt-c-blue-fg-light); + --color-accent-bg: var(--vt-c-blue-bg-light); --color-hover: var(--vt-c-blue-fg-dark); + --color-error-bg: var(--vt-c-red-bg-light); + --color-error-fg: var(--vt-c-gray-fg-light); + + --default-popup-bg: var(--vt-c-green-fg-light); + --section-gap: 160px; } @@ -91,6 +101,12 @@ --color-heading: var(--vt-c-text-dark-1); --color-text: var(--vt-c-text-dark-2); --color-accent: var(--vt-c-blue-fg-dark); + --color-accent-bg: var(--vt-c-blue-bg-dark); + + --color-error-bg: var(--vt-c-red-bg-dark); + --color-error-fg: var(--vt-c-gray-fg-dark); + + --default-popup-bg: var(--vt-c-green-fg-dark); } } @@ -128,3 +144,4 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } + diff --git a/frontend/src/components/Map.vue b/frontend/src/components/Map.vue index 73b6a35..102ce3e 100644 --- a/frontend/src/components/Map.vue +++ b/frontend/src/components/Map.vue @@ -70,7 +70,7 @@ import VectorLayer from 'ol/layer/Vector'; import View from 'ol/View'; import { useGeographic } from 'ol/proj'; -import type { Nullable } from '../models/Types'; +import type { Optional } from '../models/Types'; import Api from '../mixins/Api.vue'; import Dates from '../mixins/Dates.vue'; import FilterButton from './filter/ToggleButton.vue'; @@ -108,13 +108,13 @@ export default { data() { return { loading: false, - map: null as Nullable<Map>, - mapView: null as Nullable<View>, - pointsLayer: null as Nullable<VectorLayer>, - popup: null as Nullable<Overlay>, + map: null as Optional<Map>, + mapView: null as Optional<View>, + pointsLayer: null as Optional<VectorLayer>, + popup: null as Optional<Overlay>, queryInitialized: false, - routesLayer: null as Nullable<VectorLayer>, - selectedPoint: null as Nullable<GPSPoint>, + routesLayer: null as Optional<VectorLayer>, + selectedPoint: null as Optional<GPSPoint>, showControls: false, showMetrics: new TimelineMetricsConfiguration(), } @@ -361,13 +361,7 @@ export default { @use "@/styles/common.scss" as *; @import "ol/ol.css"; -$timeline-height: 10em; - -html, -body { - margin: 0; - height: 100%; -} +$timeline-height: 10rem; main { width: 100%; diff --git a/frontend/src/components/Messages.vue b/frontend/src/components/Messages.vue new file mode 100644 index 0000000..180c8ad --- /dev/null +++ b/frontend/src/components/Messages.vue @@ -0,0 +1,102 @@ +<template> + <div class="messages"> + <div class="message" + :class="messageClasses[message.id]" + v-for="message in messages" + :key="message.id"> + <PopupMessage :icon="message.icon" + :isError="message.isError" + @click="onClick(message.id)"> + {{ message.content }} + </PopupMessage> + </div> + </div> +</template> + +<script lang="ts"> +import Message from '../models/Message'; +import PopupMessage from '../elements/PopupMessage.vue'; + +export default { + components: { + PopupMessage, + }, + + data() { + return { + messageClasses: {} as { [key: string]: string }, + messages: [] as Message[], + }; + }, + + computed: { + messageIndexById(): { [key: string]: number } { + return this.messages.reduce( + (acc: { [key: string]: number }, message: Message, index: number) => { + acc[message.id] = index; + return acc; + }, + {} as { [key: string]: number } + ); + }, + }, + + methods: { + addMessage(message: any) { + if (!(message instanceof Message)) { + message = new Message(message); + } + + this.messages.push(message); + if (message.timeout) { + setTimeout( + () => this.removeMessage(message.id), + message.timeout + ); + } + }, + + removeMessage(id: string, timeout: number = 500) { + const msg = this.messages[this.messageIndexById[id]]; + this.messageClasses[id] = 'remove'; + setTimeout( + () => { + const index = this.messageIndexById[msg?.id] + if (index != null) { + this.messages.splice(index, 1) + } + }, timeout + ); + }, + + onClick(id: string) { + const msg = this.messages[this.messageIndexById[id]]; + msg.onClick && msg.onClick(); + this.removeMessage(id, 0); + }, + }, + + mounted() { + // @ts-ignore + this.$msgBus.on('message', this.addMessage); + }, +} +</script> + +<style scoped lang="scss"> +.messages { + position: fixed; + bottom: 1rem; + right: 1rem; + z-index: 1000; + + .message { + margin-top: 0.5rem; + animation: slide-up 0.5s ease-out; + + &.remove { + animation: slide-down 0.5s ease-out; + } + } +} +</style> diff --git a/frontend/src/components/filter/Form.vue b/frontend/src/components/filter/Form.vue index b5147c0..052b2ed 100644 --- a/frontend/src/components/filter/Form.vue +++ b/frontend/src/components/filter/Form.vue @@ -329,8 +329,8 @@ export default { @use "@/styles/common.scss"; .filter-view { - background: var(--color-background); height: 100%; + background: var(--color-background); display: flex; flex-direction: column; align-items: center; @@ -341,6 +341,18 @@ export default { margin-bottom: 0.25em; box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.66); + @include common.tablet { + min-width: 45em; + } + + @include common.desktop { + min-width: 45em; + } + + @include common.mobile { + width: 100vw; + } + .date-selectors { display: flex; justify-content: space-between; diff --git a/frontend/src/elements/Loading.vue b/frontend/src/elements/Loading.vue new file mode 100644 index 0000000..f93c3cd --- /dev/null +++ b/frontend/src/elements/Loading.vue @@ -0,0 +1,40 @@ +<template> + <div class="loading"> + <div class="loading__spinner" /> + </div> +</template> + +<script lang="ts"> +export default { +} +</script> + +<style lang="scss" scoped> +.loading { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + position: absolute; + left: 0; + + &__spinner { + width: 2em; + height: 2em; + border: 5px solid #f3f3f3; + border-top: 5px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } +} +</style> diff --git a/frontend/src/elements/PopupMessage.vue b/frontend/src/elements/PopupMessage.vue new file mode 100644 index 0000000..56289dd --- /dev/null +++ b/frontend/src/elements/PopupMessage.vue @@ -0,0 +1,76 @@ +<template> + <div class="popup-message" :class="{ error: isError }" @click="$emit('click')"> + <div class="icon"> + <font-awesome-icon :icon="iconClass" /> + </div> + + <div class="message"> + <slot></slot> + </div> + </div> +</template> + +<script lang="ts"> +export default { + emits: ['click'], + props: { + icon: { + type: String, + default: 'info-circle', + }, + + isError: { + type: Boolean, + default: false, + }, + }, + + computed: { + iconClass() { + const baseClass = this.isError ? 'triangle-exclamation' : this.icon; + return `fas fa-${baseClass}`; + }, + }, +} +</script> + +<style scoped lang="scss"> +.popup-message { + display: flex; + align-items: center; + padding: 1rem; + border-radius: 0.25rem; + background-color: var(--default-popup-bg); + font-size: 1rem; + opacity: 0.9; + box-shadow: 0 0 0.25rem 0.25rem var(--color-border); + cursor: pointer; + + .icon { + height: 100%; + margin-right: 1rem; + font-size: 1.5rem; + } + + &.error { + background: var(--color-error-bg); + color: var(--vt-c-text-dark-1); + } + + .icon { + margin-right: 1rem; + } + + &:hover { + opacity: 1; + box-shadow: 0 0 0.5rem 0.25rem #888; + } + + .message { + height: 100%; + display: flex; + flex: 1; + align-items: center; + } +} +</style> diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 50731ac..d9d640b 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,9 +1,13 @@ import './assets/main.css' import { createApp } from 'vue' +import { useStorage } from '@vueuse/core' + import App from './App.vue' import router from './router' +import mitt from 'mitt' + /* import the fontawesome core */ import { library } from '@fortawesome/fontawesome-svg-core' @@ -17,7 +21,16 @@ import { far } from '@fortawesome/free-regular-svg-icons' /* add icons to the library */ library.add(fas, far) +/* set up the storage */ +const storage = useStorage('app-storage', { + user: null, + userSession: null, +}) + const app = createApp(App) - .component('font-awesome-icon', FontAwesomeIcon) - .use(router) - .mount('#app') + +app.component('font-awesome-icon', FontAwesomeIcon) +app.use(router) +app.config.globalProperties.$storage = storage +app.config.globalProperties.$msgBus = mitt() +app.mount('#app') diff --git a/frontend/src/mixins/Api.vue b/frontend/src/mixins/Api.vue index 6dd3466..f20615e 100644 --- a/frontend/src/mixins/Api.vue +++ b/frontend/src/mixins/Api.vue @@ -1,39 +1,13 @@ <script lang="ts"> -import GPSPoint from '../models/GPSPoint'; -import LocationQuery from '../models/LocationQuery'; - -// @ts-ignore -const baseURL = __API_PATH__ +import Auth from './api/Auth.vue' +import GPSData from './api/GPSData.vue' +import Users from './api/Users.vue' export default { - methods: { - async fetchPoints(query: LocationQuery): Promise<GPSPoint[]> { - const response = await fetch( - `${baseURL}/gpsdata?` + new URLSearchParams( - Object.entries(query).reduce((acc: any, [key, value]) => { - if (value != null && key != 'data') { - acc[key] = value - } - - if (value instanceof Date) { - acc[key] = value.getTime() - } - - return acc - }, {}) - ) - ) - - return (await response.json()) - .map((gps: any) => { - return new GPSPoint({ - ...gps, - // Normalize timestamp to Date object - timestamp: new Date(gps.timestamp), - }) - }) - .sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime()) - }, - }, + mixins: [ + Auth, + GPSData, + Users, + ], } </script> diff --git a/frontend/src/mixins/api/Auth.vue b/frontend/src/mixins/api/Auth.vue new file mode 100644 index 0000000..850c52e --- /dev/null +++ b/frontend/src/mixins/api/Auth.vue @@ -0,0 +1,26 @@ +<script lang="ts"> +import { type Optional } from '../../models/Types'; +import Common from './Common.vue'; + +export default { + mixins: [Common], + methods: { + async login(payload: any): Promise<Optional<string>> { + const response = await this.request(`/auth`, { + method: 'POST', + body: payload + }) + + return response?.session?.token; + }, + + async logout(): Promise<any> { + await this.request(`/auth`, { + method: 'DELETE', + }); + + this.$router.push('/login'); + }, + }, +} +</script> diff --git a/frontend/src/mixins/api/Common.vue b/frontend/src/mixins/api/Common.vue new file mode 100644 index 0000000..7252628 --- /dev/null +++ b/frontend/src/mixins/api/Common.vue @@ -0,0 +1,69 @@ +<script lang="ts"> +import type { Optional } from '../../models/Types'; + +// @ts-ignore +const baseURL = __API_PATH__ + +export default { + methods: { + async request(path: string, params?: { + method?: string, + query?: Optional<Record<string, any>>, + body?: Optional<any>, + }): Promise<any> { + let { method, query, body } = params || {} + if (!method?.length) { + method = 'GET' + } + + try { + const response = await fetch( + `${baseURL}${path}` + ( + query ? '?' + new URLSearchParams( + Object.entries(query).reduce((acc: any, [key, value]) => { + if (value != null && key != 'data') { + acc[key] = value + } + + if (value instanceof Date) { + acc[key] = value.getTime() + } + + return acc + }, {}) + ) : '' + ), { + method, + headers: { + 'Content-Type': 'application/json', + }, + ...(body ? { body: JSON.stringify(body) } : {}), + } + ) + + let result = null + try { + result = await response.json() + } catch (error) { + result = response + } + + if (!response.ok) { + throw new Error( + `${method} ${path}: ${response.status}: ${result?.error || response.statusText}` + ) + } + + return result + } catch (error) { + const e = (error as any)?.message || error + // @ts-ignore + this.$msgBus.emit('message', { + content: e, + isError: true, + }) + } + }, + }, +} +</script> diff --git a/frontend/src/mixins/api/GPSData.vue b/frontend/src/mixins/api/GPSData.vue new file mode 100644 index 0000000..05f2b3f --- /dev/null +++ b/frontend/src/mixins/api/GPSData.vue @@ -0,0 +1,25 @@ +<script lang="ts"> +import GPSPoint from '../../models/GPSPoint'; +import LocationQuery from '../../models/LocationQuery'; +import Common from './Common.vue'; + +export default { + mixins: [Common], + methods: { + async fetchPoints(query: LocationQuery): Promise<GPSPoint[]> { + const points = await this.request('/gpsdata', { + query: query as Record<string, any> + }) || [] + + return points.map((gps: any) => + new GPSPoint({ + ...gps, + // Normalize timestamp to Date object + timestamp: new Date(gps.timestamp), + }) + ) + .sort((a: GPSPoint, b: GPSPoint) => a.timestamp.getTime() - b.timestamp.getTime()) + }, + }, +} +</script> diff --git a/frontend/src/mixins/api/Users.vue b/frontend/src/mixins/api/Users.vue new file mode 100644 index 0000000..d74546b --- /dev/null +++ b/frontend/src/mixins/api/Users.vue @@ -0,0 +1,13 @@ +<script lang="ts"> +import AuthInfo from '../../models/AuthInfo'; +import Common from './Common.vue'; + +export default { + mixins: [Common], + methods: { + async fetchUser(userId: string | number = 'me'): Promise<AuthInfo> { + return (await this.request(`/users/${userId}`)) as AuthInfo; + }, + }, +} +</script> diff --git a/frontend/src/models/AuthInfo.ts b/frontend/src/models/AuthInfo.ts new file mode 100644 index 0000000..3d3ec7f --- /dev/null +++ b/frontend/src/models/AuthInfo.ts @@ -0,0 +1,15 @@ +import type { Optional } from "./Types"; +import User from "./User"; +import UserSession from "./UserSession"; + +class AuthInfo { + public user: Optional<User>; + public userSession: Optional<UserSession>; + + constructor(user?: User, userSession?: UserSession) { + this.user = user || null; + this.userSession = userSession || null; + } +} + +export default AuthInfo; diff --git a/frontend/src/models/Message.ts b/frontend/src/models/Message.ts new file mode 100644 index 0000000..3685d3e --- /dev/null +++ b/frontend/src/models/Message.ts @@ -0,0 +1,26 @@ +class Message { + public id: string; + public content: string; + public icon?: string; + public isError: boolean; + public timeout?: number; + public onClick: (...args: any[]) => void = () => {}; + + constructor(msg: { + id?: string; + content: string; + icon?: string; + isError?: boolean; + timeout?: number; + onClick?: (...args: any[]) => void; + }) { + this.id = msg.id || Math.random().toString(36).substring(2, 11); + this.content = msg.content; + this.icon = msg.icon; + this.isError = msg.isError || false; + this.timeout = msg.timeout !== undefined ? msg.timeout : 5000; + this.onClick = msg.onClick || this.onClick; + } +} + +export default Message; diff --git a/frontend/src/models/Types.ts b/frontend/src/models/Types.ts index aa32b2d..a538e86 100644 --- a/frontend/src/models/Types.ts +++ b/frontend/src/models/Types.ts @@ -1 +1,5 @@ -export type Nullable<T> = T | null; +type Optional<T> = T | null; + +export { + type Optional, +}; diff --git a/frontend/src/models/User.ts b/frontend/src/models/User.ts new file mode 100644 index 0000000..897a791 --- /dev/null +++ b/frontend/src/models/User.ts @@ -0,0 +1,31 @@ +import type { Optional } from "./Types"; + +class User { + public id: number; + public username: number; + public email: string; + public firstName: Optional<string>; + public lastName: Optional<string>; + public createdAt: Optional<Date>; + public updatedAt: Optional<Date>; + + constructor(user: { + id: number; + username: number; + email: string; + firstName?: string; + lastName?: string; + createdAt?: Date; + updatedAt?: Date; + }) { + this.id = user.id; + this.username = user.username; + this.email = user.email; + this.firstName = user.firstName || null; + this.lastName = user.lastName || null; + this.createdAt = user.createdAt || null; + this.updatedAt = user.updatedAt || null; + } +} + +export default User; diff --git a/frontend/src/models/UserSession.ts b/frontend/src/models/UserSession.ts new file mode 100644 index 0000000..b40f1cc --- /dev/null +++ b/frontend/src/models/UserSession.ts @@ -0,0 +1,25 @@ +import type { Optional } from "./Types"; + +class UserSession { + public id: string; + public userId: number; + public createdAt: Date; + public updatedAt: Date; + public expiresAt: Optional<Date>; + + constructor(userSession: { + id: string; + userId: number; + createdAt: Date; + updatedAt: Date; + expiresAt?: Date; + }) { + this.id = userSession.id; + this.userId = userSession.userId; + this.createdAt = userSession.createdAt; + this.updatedAt = userSession.updatedAt; + this.expiresAt = userSession.expiresAt || null; + } +} + +export default UserSession; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6ab004a..fc50124 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,5 +1,7 @@ import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' +import Login from '../views/Login.vue' +import Logout from '../views/Logout.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -9,6 +11,19 @@ const router = createRouter({ name: 'home', component: HomeView, }, + + { + path: '/login', + name: 'login', + component: Login, + }, + + { + path: '/logout', + name: 'logout', + component: Logout, + }, + //{ // path: '/about', // name: 'about', diff --git a/frontend/src/styles/common.scss b/frontend/src/styles/common.scss index df11811..1b34d6f 100644 --- a/frontend/src/styles/common.scss +++ b/frontend/src/styles/common.scss @@ -35,6 +35,7 @@ button { cursor: pointer; &:disabled { + color: var(--color-text); opacity: 0.5; cursor: not-allowed; } @@ -44,8 +45,70 @@ button { } } -button[type=submit] { +[type=submit] { + background: var(--color-accent); + color: white !important; margin: 0.5em; font-size: 1.15em; padding: 0.5em; + transition: background-color 0.5s; + + &:hover { + background: var(--color-hover); + color: var(--color-background); + } +} + +[type=text], [type=email], [type=password], textarea { + width: 100%; + background: var(--color-background-soft); + color: var(--color-text); + padding: 0.5em; + border: 1px solid var(--color-border); + border-radius: 4px; + transition: border-color 0.5s; + transition: border-color 0.5s; + // + width: 100%; + padding: 0.5em; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-background-soft); +} + +/* Animations */ +@keyframes slide-up { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +@keyframes slide-down { + from { + transform: translateY(0); + } + to { + transform: translateY(100%); + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } } diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..de0969c --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,123 @@ +<template> + <main> + <Loading v-if="loading" /> + <form @submit.prevent="onSubmit"> + <h1 class="title"> + <font-awesome-icon icon="map-marker-alt" /> + GPSTracker + </h1> + + <div class="row"> + <input type="text" + placeholder="Username" + v-model="username" + :disabled="loading" + ref="username" /> + </div> + + <div class="row"> + <input type="password" + placeholder="Password" + :disabled="loading" + v-model="password" /> + </div> + + <div class="row"> + <button type="submit" :disabled="loading"> + <font-awesome-icon icon="sign-in-alt" /> + Login + </button> + </div> + </form> + </main> +</template> + +<script lang="ts"> +import Api from '../mixins/Api.vue'; +import Loading from '../elements/Loading.vue'; + +export default { + mixins: [Api], + components: { + Loading, + }, + + data() { + return { + loading: false, + username: '', + password: '', + }; + }, + + methods: { + async onSubmit() { + if (this.loading) { + return; + } + + this.loading = true; + const redirect = this.$route.query.redirect || '/'; + + try { + const sessionToken = await this.login({ + username: this.username, + password: this.password, + }); + + if (sessionToken?.length) { + window.location.href = redirect as string; + } + } catch (error) { + // @ts-ignore + this.$msgBus.emit('message', { + content: (error as any)?.message || error, + isError: true, + }) + } finally { + this.loading = false; + } + }, + }, + + mounted() { + this.$nextTick(() => { + (this.$refs.username as HTMLElement).focus(); + }); + }, +}; +</script> + +<style lang="scss" scoped> +main { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + background: var(--vt-c-green-bg-light); +} + +form { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 25em; + max-width: 80%; + background: var(--color-background); + border: 1px solid var(--color-border); + box-shadow: 2px 2px 2px 2px var(--color-border); + border-radius: 0.5em; + padding: 1em; + + .row { + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + padding: 0.5em; + } +} +</style> diff --git a/frontend/src/views/Logout.vue b/frontend/src/views/Logout.vue new file mode 100644 index 0000000..32f4dfb --- /dev/null +++ b/frontend/src/views/Logout.vue @@ -0,0 +1,19 @@ +<template> + <Loading /> +</template> + +<script lang="ts"> +import Api from '../mixins/Api.vue'; +import Loading from '../elements/Loading.vue'; + +export default { + mixins: [Api], + components: { + Loading, + }, + + async mounted() { + await this.logout(); + }, +}; +</script> diff --git a/src/auth.ts b/src/auth.ts index 0f820c1..307f279 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -23,7 +23,7 @@ function authenticate(roles: RoleName[] = []) { let session: Optional<UserSession>; // Check the `session` cookie or the `Authorization` header for the session token - let token = req.cookies?.session; + let token = req.headers?.cookie?.match(/session=([^;]+)/)?.[1]; if (!token?.length) { const authHeader = req.headers?.authorization; if (authHeader?.startsWith('Bearer ')) { diff --git a/src/routes/Route.ts b/src/routes/Route.ts index 36ae611..55c8ffa 100644 --- a/src/routes/Route.ts +++ b/src/routes/Route.ts @@ -37,17 +37,23 @@ abstract class Route { } if (error instanceof Forbidden) { - res.status(403).send(error.message); + res.status(403).json({ + error: error.message, + }); return; } if (error instanceof NotFound) { - res.status(404).send(error.message); + res.status(404).json({ + error: error.message, + }); return; } if (error instanceof BadRequest) { - res.status(400).send(error.message); + res.status(400).json({ + error: error.message, + }); return; } diff --git a/src/routes/api/Route.ts b/src/routes/api/Route.ts index d9669ad..611ee5f 100644 --- a/src/routes/api/Route.ts +++ b/src/routes/api/Route.ts @@ -13,7 +13,10 @@ abstract class ApiRoute extends Route { protected static handleError(req: Request, res: Response, error: Error) { // Handle API unauthorized errors with a 401+JSON response instead of a redirect if (error instanceof Unauthorized) { - res.status(401).send(error.message); + res.status(401).json({ + error: error.message, + }); + return; } diff --git a/src/routes/api/v1/Auth.ts b/src/routes/api/v1/Auth.ts index e4e2fbe..69b1190 100644 --- a/src/routes/api/v1/Auth.ts +++ b/src/routes/api/v1/Auth.ts @@ -62,7 +62,7 @@ class Auth extends ApiV1Route { * If the user is authenticated, the session will be destroyed and the cookie will be cleared. */ @authenticate() - delete = async (_: Request, res: Response, auth: Optional<AuthInfo>) => { + delete = async (_: Request, res: Response, auth?: Optional<AuthInfo>) => { const session = auth!.session; if (session) {