From 2ab01ec560584d71c6681616179aeb4e3170d757 Mon Sep 17 00:00:00 2001
From: BluemediaDev <oliver@traber-info.de>
Date: Tue, 25 Mar 2025 17:30:03 +0000
Subject: [PATCH 1/3] Implement authentication logic

---
 backend/app/main.py                         |  14 +++
 frontend/src/lib/axios.svelte.ts            | 103 ++++++++++++++++++++
 frontend/src/lib/persistent-store.ts        |  13 ++-
 frontend/src/routes/(navbar)/+layout.svelte |   4 +-
 frontend/src/routes/login/+page.svelte      |  64 +++++++++---
 frontend/static/locales/de/login.json       |   3 +-
 frontend/static/locales/en/login.json       |   3 +-
 7 files changed, 184 insertions(+), 20 deletions(-)
 create mode 100644 frontend/src/lib/axios.svelte.ts

diff --git a/backend/app/main.py b/backend/app/main.py
index b36e999..5a69345 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,5 +1,6 @@
 from dotenv import load_dotenv
 from fastapi import APIRouter, FastAPI
+from fastapi.middleware.cors import CORSMiddleware
 from starlette.middleware.authentication import AuthenticationMiddleware
 
 load_dotenv()
@@ -47,6 +48,19 @@ def create_app():
     app.include_router(api_v1_router)
     app.mount(path="/v1/ocpp", app=create_ocpp_app())
 
+    origins = [
+        "http://localhost",
+        "http://localhost:5173",
+    ]
+
+    app.add_middleware(
+        CORSMiddleware,
+        allow_origins=origins,
+        allow_credentials=True,
+        allow_methods=["*"],
+        allow_headers=["*"],
+    )
+
     return app
 
 app = create_app()
diff --git a/frontend/src/lib/axios.svelte.ts b/frontend/src/lib/axios.svelte.ts
new file mode 100644
index 0000000..31ad377
--- /dev/null
+++ b/frontend/src/lib/axios.svelte.ts
@@ -0,0 +1,103 @@
+import axios from 'axios';
+import { dev } from '$app/environment';
+import { goto } from '$app/navigation';
+import { get } from 'svelte/store';
+import { persistentSettings, clearLoginState } from '$lib/persistent-store';
+
+if (dev) {
+  axios.defaults.baseURL = "http://localhost:8000/api/v1"
+} else {
+  axios.defaults.baseURL = "/api/v1"
+}
+
+function createTokenRefreshInterceptor() {
+  const interceptor = axios.interceptors.response.use(
+    (response) => response,
+    (error) => {
+      // Reject promise if usual error
+      if (error.response.status !== 401) {
+        return Promise.reject(error);
+      }
+
+      /*
+      * When response code is 401, try to refresh the token.
+      * Eject the interceptor so it doesn't loop in case
+      * token refresh causes the 401 response.
+      *
+      * Must be re-attached later on or the token refresh will only happen once
+      */
+      axios.interceptors.response.eject(interceptor);
+
+      return axios
+        .post("/auth/refresh", {
+          refresh_token: get(persistentSettings).refreshToken,
+        })
+        .then((response) => {
+          // Save new refresh token
+          persistentSettings.update(settings => {
+            settings.refreshToken = response.data.refresh_token;
+            return settings;
+          })
+
+          // Update access token
+          const authHeader = "Bearer " + response.data.access_token;
+          axios.defaults.headers.common['Authorization'] = authHeader;
+          error.response.config.headers["Authorization"] = authHeader;
+
+          // Retry initial request with new token
+          return axios(error.response.config);
+        })
+        .catch((retryError) => {
+          // Retry failed, clean up and reject the promise
+          clearLoginState();
+          axios.defaults.headers.common['Authorization'] = "";
+          goto('/login?reauth')
+          return Promise.reject(retryError);
+        })
+        .finally(createTokenRefreshInterceptor); // Re-attach interceptor for future requests
+      }
+  );
+}
+
+createTokenRefreshInterceptor();
+
+export const login = async function(email: string, password: string) {
+  await axios
+  .post('/auth/login', {
+    email,
+    password,
+  })
+  .then((response) => {
+    persistentSettings.update(settings => {
+      settings.loggedIn = true
+      settings.refreshToken = response.data.refresh_token
+      return settings;
+    })
+    axios.defaults.headers.common['Authorization'] = 'Bearer ' + response.data.access_token
+    axios.get('/me').then((response) => {
+      persistentSettings.update(settings => {
+        settings.email = response.data.email
+        settings.friendlyName = response.data.friendly_name
+        settings.role = response.data.role
+        return settings;
+      })
+    })
+    goto('/')
+    return Promise.resolve();
+  })
+  .catch((error) => {
+    return Promise.reject(error);
+  })
+}
+
+export const logout = function() {
+  axios
+  .post('/auth/logout')
+  .then(() => {
+    clearLoginState();
+    axios.defaults.headers.common['Authorization'] = "";
+    goto('/login?logout')
+  });
+}
+
+export default axios;
diff --git a/frontend/src/lib/persistent-store.ts b/frontend/src/lib/persistent-store.ts
index 81b4fea..7e0c04c 100644
--- a/frontend/src/lib/persistent-store.ts
+++ b/frontend/src/lib/persistent-store.ts
@@ -20,4 +20,15 @@ const settingsDefault: PersistedSettings = {
 
 export const persistentSettings = writable<PersistedSettings>(JSON.parse(localStorage.getItem('persistentSettings') || JSON.stringify(settingsDefault)))
 
-persistentSettings.subscribe((value) => localStorage.persistentSettings = JSON.stringify(value))
\ No newline at end of file
+persistentSettings.subscribe((value) => localStorage.persistentSettings = JSON.stringify(value))
+
+export const clearLoginState = function() {
+  persistentSettings.update(settings => {
+    settings.refreshToken = "";
+    settings.loggedIn = false;
+    settings.friendlyName = "";
+    settings.email = "";
+    settings.role = "member";
+    return settings;
+  })
+}
\ No newline at end of file
diff --git a/frontend/src/routes/(navbar)/+layout.svelte b/frontend/src/routes/(navbar)/+layout.svelte
index 191f2f6..b58d427 100644
--- a/frontend/src/routes/(navbar)/+layout.svelte
+++ b/frontend/src/routes/(navbar)/+layout.svelte
@@ -2,6 +2,8 @@
   import { goto } from '$app/navigation'
   import { persistentSettings } from '$lib/persistent-store'
   import i18n from '$lib/i18n'
+  import { logout } from '$lib/axios.svelte'
+
   let { children } = $props()
 
   if (!$persistentSettings.loggedIn) {
@@ -86,7 +88,7 @@
           class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
         >
           <li><a href="/profile">{$i18n.t('common:navbar.link.profile')}</a></li>
-          <li><a href="/login?logout">{$i18n.t('common:navbar.link.logout')}</a></li>
+          <li><button onclick={logout}>{$i18n.t('common:navbar.link.logout')}</button></li>
         </ul>
       </div>
     </div>
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte
index 038f95f..a9e3b16 100644
--- a/frontend/src/routes/login/+page.svelte
+++ b/frontend/src/routes/login/+page.svelte
@@ -1,18 +1,37 @@
 <script lang="ts">
   import { fly } from 'svelte/transition'
   import i18n from '$lib/i18n'
+  import { login as performLogin } from '$lib/axios.svelte'
   $i18n.loadNamespaces('login')
 
-  const urlParams = new URLSearchParams(window.location.search)
-  const isReauth = urlParams.has('reauth')
-  const isLogout = urlParams.has('logout')
+  type ToastType = 'reauth' | 'logout' | 'error'
+  let toastType: ToastType = $state('reauth')
+  let toastVisible = $state(false)
 
-  let showToast = $state(true)
-  if (isReauth || isLogout) {
+  function showToast(type: ToastType) {
+    toastType = type
+    toastVisible = true
     setTimeout(() => {
-      showToast = false
+      toastVisible = false
     }, 6000)
   }
+
+  const urlParams = new URLSearchParams(window.location.search)
+  if (urlParams.has('reauth')) {
+    showToast('reauth')
+  }
+  if (urlParams.has('logout')) {
+    showToast('logout')
+  }
+
+  let email: string = $state('')
+  let password: string = $state('')
+
+  async function login() {
+    await performLogin(email, password).catch(() => {
+      showToast('error')
+    })
+  }
 </script>
 
 <div class="w-full h-full flex flex-col justify-center items-center bg-base-200">
@@ -30,28 +49,41 @@
         <fieldset class="fieldset">
           <label class="fieldset-label input text-base-content">
             <i class="bi bi-envelope-at"></i>
-            <input type="email" placeholder="me@example.com" required />
+            <input type="email" bind:value={email} placeholder="me@example.com" required />
           </label>
           <label class="fieldset-label input text-base-content">
             <i class="bi bi-key"></i>
-            <input type="password" placeholder={$i18n.t('login:passwordPlaceholder')} required />
+            <input
+              type="password"
+              bind:value={password}
+              placeholder={$i18n.t('login:passwordPlaceholder')}
+              required
+            />
           </label>
-          <button class="btn btn-primary mt-4">{$i18n.t('login:button')}</button>
+          <button class="btn btn-primary mt-4" onclick={login}>{$i18n.t('login:button')}</button>
         </fieldset>
       </div>
     </div>
   </div>
   <span class="basis-0 grow"></span>
-  {#if (isReauth || isLogout) && showToast}
+  {#if toastVisible}
     <div class="toast toast-top" out:fly={{ x: 200 }}>
-      <div class="alert text-base {isReauth ? 'alert-warning' : 'alert-success'}">
-        {#if isReauth}
+      {#if toastType === 'reauth'}
+        <div class="alert text-base alert-warning">
           <i class="bi bi-exclamation-diamond text-xl"></i>
-        {:else}
+          <span>{$i18n.t('login:toast.reauth')}</span>
+        </div>
+      {:else if toastType === 'logout'}
+        <div class="alert text-base alert-success">
           <i class="bi bi-check-circle text-xl"></i>
-        {/if}
-        <span>{$i18n.t(isReauth ? 'login:toast.reauth' : 'login:toast.logoutSuccess')}</span>
-      </div>
+          <span>{$i18n.t('login:toast.logoutSuccess')}</span>
+        </div>
+      {:else if toastType === 'error'}
+        <div class="alert text-base alert-error">
+          <i class="bi bi-x-circle text-xl"></i>
+          <span>{$i18n.t('login:toast.loginFailed')}</span>
+        </div>
+      {/if}
     </div>
   {/if}
 </div>
diff --git a/frontend/static/locales/de/login.json b/frontend/static/locales/de/login.json
index 69f7392..f744f34 100644
--- a/frontend/static/locales/de/login.json
+++ b/frontend/static/locales/de/login.json
@@ -5,6 +5,7 @@
   "button": "Anmelden",
   "toast": {
     "reauth": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
-    "logoutSuccess": "Abmeldung erfolgreich."
+    "logoutSuccess": "Abmeldung erfolgreich.",
+    "loginFailed": "Anmeldung fehlgeschlagen."
   }
 }
diff --git a/frontend/static/locales/en/login.json b/frontend/static/locales/en/login.json
index 6d600d6..10be868 100644
--- a/frontend/static/locales/en/login.json
+++ b/frontend/static/locales/en/login.json
@@ -5,6 +5,7 @@
   "button": "Login",
   "toast": {
     "reauth": "Your session expired. Please log in again.",
-    "logoutSuccess": "Successfully logged out."
+    "logoutSuccess": "Successfully logged out.",
+    "loginFailed": "Login failed."
   }
 }

From be736638bdb9e525bbd91547101f46e7209b2362 Mon Sep 17 00:00:00 2001
From: BluemediaDev <oliver@traber-info.de>
Date: Tue, 25 Mar 2025 17:30:03 +0000
Subject: [PATCH 2/3] Implement authentication logic

---
 backend/app/main.py                           |  14 +++
 frontend/src/lib/axios.svelte.ts              | 108 ++++++++++++++++++
 frontend/src/lib/i18n.ts                      |   2 +-
 .../src/lib/{i18n-store.ts => i18n_store.ts}  |   0
 ...ersistent-store.ts => persistent_store.ts} |  16 ++-
 frontend/src/routes/(navbar)/+layout.svelte   |   6 +-
 frontend/src/routes/(navbar)/+page.svelte     |   2 +-
 frontend/src/routes/+layout.svelte            |   2 +-
 frontend/src/routes/login/+page.svelte        |  64 ++++++++---
 frontend/static/locales/de/login.json         |   3 +-
 frontend/static/locales/en/login.json         |   3 +-
 11 files changed, 196 insertions(+), 24 deletions(-)
 create mode 100644 frontend/src/lib/axios.svelte.ts
 rename frontend/src/lib/{i18n-store.ts => i18n_store.ts} (100%)
 rename frontend/src/lib/{persistent-store.ts => persistent_store.ts} (60%)

diff --git a/backend/app/main.py b/backend/app/main.py
index b36e999..5a69345 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,5 +1,6 @@
 from dotenv import load_dotenv
 from fastapi import APIRouter, FastAPI
+from fastapi.middleware.cors import CORSMiddleware
 from starlette.middleware.authentication import AuthenticationMiddleware
 
 load_dotenv()
@@ -47,6 +48,19 @@ def create_app():
     app.include_router(api_v1_router)
     app.mount(path="/v1/ocpp", app=create_ocpp_app())
 
+    origins = [
+        "http://localhost",
+        "http://localhost:5173",
+    ]
+
+    app.add_middleware(
+        CORSMiddleware,
+        allow_origins=origins,
+        allow_credentials=True,
+        allow_methods=["*"],
+        allow_headers=["*"],
+    )
+
     return app
 
 app = create_app()
diff --git a/frontend/src/lib/axios.svelte.ts b/frontend/src/lib/axios.svelte.ts
new file mode 100644
index 0000000..d086718
--- /dev/null
+++ b/frontend/src/lib/axios.svelte.ts
@@ -0,0 +1,108 @@
+import axios from 'axios';
+import { dev } from '$app/environment';
+import { goto } from '$app/navigation';
+import { get } from 'svelte/store';
+import { persistentSettings, clearLoginState } from '$lib/persistent_store';
+
+if (dev) {
+  axios.defaults.baseURL = "http://localhost:8000/api/v1"
+} else {
+  axios.defaults.baseURL = "/api/v1"
+}
+
+// Get access token from local storage
+axios.defaults.headers.common['Authorization'] = "Bearer " + get(persistentSettings).accessToken;
+
+function createTokenRefreshInterceptor() {
+  const interceptor = axios.interceptors.response.use(
+    (response) => response,
+    (error) => {
+      // Reject promise if usual error
+      if (error.response.status !== 401) {
+        return Promise.reject(error);
+      }
+
+      /*
+      * When response code is 401, try to refresh the token.
+      * Eject the interceptor so it doesn't loop in case
+      * token refresh causes the 401 response.
+      *
+      * Must be re-attached later on or the token refresh will only happen once
+      */
+      axios.interceptors.response.eject(interceptor);
+
+      return axios
+        .post("/auth/refresh", {
+          refresh_token: get(persistentSettings).refreshToken,
+        })
+        .then((response) => {
+          // Save new tokens
+          persistentSettings.update(settings => {
+            settings.accessToken = response.data.access_token
+            settings.refreshToken = response.data.refresh_token;
+            return settings;
+          })
+
+          // Update access token
+          const authHeader = "Bearer " + response.data.access_token;
+          axios.defaults.headers.common['Authorization'] = authHeader;
+          error.response.config.headers["Authorization"] = authHeader;
+
+          // Retry initial request with new token
+          return axios(error.response.config);
+        })
+        .catch((retryError) => {
+          // Retry failed, clean up and reject the promise
+          clearLoginState();
+          axios.defaults.headers.common['Authorization'] = "";
+          goto('/login?reauth')
+          return Promise.reject(retryError);
+        })
+        .finally(createTokenRefreshInterceptor); // Re-attach interceptor for future requests
+      }
+  );
+}
+
+createTokenRefreshInterceptor();
+
+export const login = async function(email: string, password: string) {
+  await axios
+  .post('/auth/login', {
+    email,
+    password,
+  })
+  .then((response) => {
+    persistentSettings.update(settings => {
+      settings.loggedIn = true
+      settings.accessToken = response.data.access_token
+      settings.refreshToken = response.data.refresh_token
+      return settings;
+    })
+    axios.defaults.headers.common['Authorization'] = 'Bearer ' + response.data.access_token
+    axios.get('/me').then((response) => {
+      persistentSettings.update(settings => {
+        settings.email = response.data.email
+        settings.friendlyName = response.data.friendly_name
+        settings.role = response.data.role
+        return settings;
+      })
+    })
+    goto('/')
+    return Promise.resolve();
+  })
+  .catch((error) => {
+    return Promise.reject(error);
+  })
+}
+
+export const logout = function() {
+  axios
+  .post('/auth/logout')
+  .then(() => {
+    clearLoginState();
+    axios.defaults.headers.common['Authorization'] = "";
+    goto('/login?logout')
+  });
+}
+
+export default axios;
diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts
index bac4b7e..5a428f0 100644
--- a/frontend/src/lib/i18n.ts
+++ b/frontend/src/lib/i18n.ts
@@ -3,7 +3,7 @@ import Backend from 'i18next-chained-backend'
 import Fetch from 'i18next-fetch-backend'
 import LocalStorageBackend from 'i18next-localstorage-backend'
 import I18nextBrowserLanguageDetector from 'i18next-browser-languagedetector'
-import { createI18nStore } from './i18n-store'
+import { createI18nStore } from './i18n_store'
 
 i18next
   .use(Backend)
diff --git a/frontend/src/lib/i18n-store.ts b/frontend/src/lib/i18n_store.ts
similarity index 100%
rename from frontend/src/lib/i18n-store.ts
rename to frontend/src/lib/i18n_store.ts
diff --git a/frontend/src/lib/persistent-store.ts b/frontend/src/lib/persistent_store.ts
similarity index 60%
rename from frontend/src/lib/persistent-store.ts
rename to frontend/src/lib/persistent_store.ts
index 81b4fea..6a94816 100644
--- a/frontend/src/lib/persistent-store.ts
+++ b/frontend/src/lib/persistent_store.ts
@@ -6,6 +6,7 @@ interface PersistedSettings {
   friendlyName: string
   email: string
   role: string
+  accessToken: string,
   refreshToken: string
 }
 
@@ -15,9 +16,22 @@ const settingsDefault: PersistedSettings = {
   friendlyName: "",
   email: "",
   role: "member",
+  accessToken: "",
   refreshToken: ""
 }
 
 export const persistentSettings = writable<PersistedSettings>(JSON.parse(localStorage.getItem('persistentSettings') || JSON.stringify(settingsDefault)))
 
-persistentSettings.subscribe((value) => localStorage.persistentSettings = JSON.stringify(value))
\ No newline at end of file
+persistentSettings.subscribe((value) => localStorage.persistentSettings = JSON.stringify(value))
+
+export const clearLoginState = function() {
+  persistentSettings.update(settings => {
+    settings.accessToken = "";
+    settings.refreshToken = "";
+    settings.loggedIn = false;
+    settings.friendlyName = "";
+    settings.email = "";
+    settings.role = "member";
+    return settings;
+  })
+}
\ No newline at end of file
diff --git a/frontend/src/routes/(navbar)/+layout.svelte b/frontend/src/routes/(navbar)/+layout.svelte
index 191f2f6..f603e37 100644
--- a/frontend/src/routes/(navbar)/+layout.svelte
+++ b/frontend/src/routes/(navbar)/+layout.svelte
@@ -1,7 +1,9 @@
 <script lang="ts">
   import { goto } from '$app/navigation'
-  import { persistentSettings } from '$lib/persistent-store'
+  import { persistentSettings } from '$lib/persistent_store'
   import i18n from '$lib/i18n'
+  import { logout } from '$lib/axios.svelte'
+
   let { children } = $props()
 
   if (!$persistentSettings.loggedIn) {
@@ -86,7 +88,7 @@
           class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
         >
           <li><a href="/profile">{$i18n.t('common:navbar.link.profile')}</a></li>
-          <li><a href="/login?logout">{$i18n.t('common:navbar.link.logout')}</a></li>
+          <li><button onclick={logout}>{$i18n.t('common:navbar.link.logout')}</button></li>
         </ul>
       </div>
     </div>
diff --git a/frontend/src/routes/(navbar)/+page.svelte b/frontend/src/routes/(navbar)/+page.svelte
index 84d03e9..e96407d 100644
--- a/frontend/src/routes/(navbar)/+page.svelte
+++ b/frontend/src/routes/(navbar)/+page.svelte
@@ -1,5 +1,5 @@
 <script lang="ts">
-  import { persistentSettings } from '$lib/persistent-store'
+  import { persistentSettings } from '$lib/persistent_store'
   import { currentDaytime } from '$lib/util'
   import i18n from '$lib/i18n'
   import DashboardCard from '$lib/component/DashboardCard.svelte'
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 87913bb..b868fbe 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -1,5 +1,5 @@
 <script>
-  import { persistentSettings } from '$lib/persistent-store'
+  import { persistentSettings } from '$lib/persistent_store'
   let { children } = $props()
   import '../app.css'
 </script>
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte
index 038f95f..a9e3b16 100644
--- a/frontend/src/routes/login/+page.svelte
+++ b/frontend/src/routes/login/+page.svelte
@@ -1,18 +1,37 @@
 <script lang="ts">
   import { fly } from 'svelte/transition'
   import i18n from '$lib/i18n'
+  import { login as performLogin } from '$lib/axios.svelte'
   $i18n.loadNamespaces('login')
 
-  const urlParams = new URLSearchParams(window.location.search)
-  const isReauth = urlParams.has('reauth')
-  const isLogout = urlParams.has('logout')
+  type ToastType = 'reauth' | 'logout' | 'error'
+  let toastType: ToastType = $state('reauth')
+  let toastVisible = $state(false)
 
-  let showToast = $state(true)
-  if (isReauth || isLogout) {
+  function showToast(type: ToastType) {
+    toastType = type
+    toastVisible = true
     setTimeout(() => {
-      showToast = false
+      toastVisible = false
     }, 6000)
   }
+
+  const urlParams = new URLSearchParams(window.location.search)
+  if (urlParams.has('reauth')) {
+    showToast('reauth')
+  }
+  if (urlParams.has('logout')) {
+    showToast('logout')
+  }
+
+  let email: string = $state('')
+  let password: string = $state('')
+
+  async function login() {
+    await performLogin(email, password).catch(() => {
+      showToast('error')
+    })
+  }
 </script>
 
 <div class="w-full h-full flex flex-col justify-center items-center bg-base-200">
@@ -30,28 +49,41 @@
         <fieldset class="fieldset">
           <label class="fieldset-label input text-base-content">
             <i class="bi bi-envelope-at"></i>
-            <input type="email" placeholder="me@example.com" required />
+            <input type="email" bind:value={email} placeholder="me@example.com" required />
           </label>
           <label class="fieldset-label input text-base-content">
             <i class="bi bi-key"></i>
-            <input type="password" placeholder={$i18n.t('login:passwordPlaceholder')} required />
+            <input
+              type="password"
+              bind:value={password}
+              placeholder={$i18n.t('login:passwordPlaceholder')}
+              required
+            />
           </label>
-          <button class="btn btn-primary mt-4">{$i18n.t('login:button')}</button>
+          <button class="btn btn-primary mt-4" onclick={login}>{$i18n.t('login:button')}</button>
         </fieldset>
       </div>
     </div>
   </div>
   <span class="basis-0 grow"></span>
-  {#if (isReauth || isLogout) && showToast}
+  {#if toastVisible}
     <div class="toast toast-top" out:fly={{ x: 200 }}>
-      <div class="alert text-base {isReauth ? 'alert-warning' : 'alert-success'}">
-        {#if isReauth}
+      {#if toastType === 'reauth'}
+        <div class="alert text-base alert-warning">
           <i class="bi bi-exclamation-diamond text-xl"></i>
-        {:else}
+          <span>{$i18n.t('login:toast.reauth')}</span>
+        </div>
+      {:else if toastType === 'logout'}
+        <div class="alert text-base alert-success">
           <i class="bi bi-check-circle text-xl"></i>
-        {/if}
-        <span>{$i18n.t(isReauth ? 'login:toast.reauth' : 'login:toast.logoutSuccess')}</span>
-      </div>
+          <span>{$i18n.t('login:toast.logoutSuccess')}</span>
+        </div>
+      {:else if toastType === 'error'}
+        <div class="alert text-base alert-error">
+          <i class="bi bi-x-circle text-xl"></i>
+          <span>{$i18n.t('login:toast.loginFailed')}</span>
+        </div>
+      {/if}
     </div>
   {/if}
 </div>
diff --git a/frontend/static/locales/de/login.json b/frontend/static/locales/de/login.json
index 69f7392..f744f34 100644
--- a/frontend/static/locales/de/login.json
+++ b/frontend/static/locales/de/login.json
@@ -5,6 +5,7 @@
   "button": "Anmelden",
   "toast": {
     "reauth": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
-    "logoutSuccess": "Abmeldung erfolgreich."
+    "logoutSuccess": "Abmeldung erfolgreich.",
+    "loginFailed": "Anmeldung fehlgeschlagen."
   }
 }
diff --git a/frontend/static/locales/en/login.json b/frontend/static/locales/en/login.json
index 6d600d6..10be868 100644
--- a/frontend/static/locales/en/login.json
+++ b/frontend/static/locales/en/login.json
@@ -5,6 +5,7 @@
   "button": "Login",
   "toast": {
     "reauth": "Your session expired. Please log in again.",
-    "logoutSuccess": "Successfully logged out."
+    "logoutSuccess": "Successfully logged out.",
+    "loginFailed": "Login failed."
   }
 }

From 148275dd982eeb2dd0d2cc2b7b5a12338771523e Mon Sep 17 00:00:00 2001
From: BluemediaDev <oliver@traber-info.de>
Date: Mon, 14 Apr 2025 22:06:14 +0000
Subject: [PATCH 3/3] Backend: Change status code of token invalid error

---
 backend/app/security/jwt_bearer.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/backend/app/security/jwt_bearer.py b/backend/app/security/jwt_bearer.py
index c82a297..b2d7bd2 100644
--- a/backend/app/security/jwt_bearer.py
+++ b/backend/app/security/jwt_bearer.py
@@ -28,7 +28,7 @@ class JWTBearer(HTTPBearer):
         if credentials:
             if not credentials.scheme == "Bearer":
                 raise HTTPException(
-                    status_code=403, detail="authentication_scheme_invalid"
+                    status_code=401, detail="authentication_scheme_invalid"
                 )
             try:
                 token = await token_service.verify_access_token(
@@ -36,7 +36,7 @@ class JWTBearer(HTTPBearer):
                 )
                 if not token:
                     raise HTTPException(
-                        status_code=403, detail="token_invalid_or_expired"
+                        status_code=401, detail="token_invalid_or_expired"
                     )
                 return token
             except InsufficientPermissionsError:
@@ -44,4 +44,4 @@ class JWTBearer(HTTPBearer):
             except InvalidTokenAudienceError:
                 raise HTTPException(status_code=403, detail="invalid_token_audience")
         else:
-            raise HTTPException(status_code=403, detail="authorization_code_invalid")
\ No newline at end of file
+            raise HTTPException(status_code=401, detail="authorization_code_invalid")
\ No newline at end of file