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" />&nbsp;&nbsp;GPSTracker
+            </RouterLink>
+          </li>
 
-  <RouterView />
+          <div class="spacer" />
+
+          <li class="right">
+            <RouterLink to="/logout">
+              <font-awesome-icon icon="sign-out-alt" />&nbsp;&nbsp;
+              <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) {