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(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 @@ 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 @@
@@ -30,28 +49,41 @@
- +
- {#if (isReauth || isLogout) && showToast} + {#if toastVisible}
-
- {#if isReauth} + {#if toastType === 'reauth'} +
- {:else} + {$i18n.t('login:toast.reauth')} +
+ {:else if toastType === 'logout'} +
- {/if} - {$i18n.t(isReauth ? 'login:toast.reauth' : 'login:toast.logoutSuccess')} -
+ {$i18n.t('login:toast.logoutSuccess')} +
+ {:else if toastType === 'error'} +
+ + {$i18n.t('login:toast.loginFailed')} +
+ {/if}
{/if} 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." } }