]> git.ipfire.org Git - pbs.git/commitdiff
frontend: Store login information in presistent storage
authorMichael Tremer <michael.tremer@ipfire.org>
Fri, 20 Jun 2025 11:40:57 +0000 (11:40 +0000)
committerMichael Tremer <michael.tremer@ipfire.org>
Fri, 20 Jun 2025 11:40:57 +0000 (11:40 +0000)
Signed-off-by: Michael Tremer <michael.tremer@ipfire.org>
frontend/src/App.vue
frontend/src/stores/auth.ts [new file with mode: 0644]
frontend/src/types/User.ts [new file with mode: 0644]
frontend/src/utils/auth.ts [new file with mode: 0644]
frontend/src/utils/fetchWithAuth.ts [new file with mode: 0644]
frontend/src/utils/jwt.ts [new file with mode: 0644]
frontend/src/views/LoginView.vue

index fbcdcd133b9e1fd85d1978e467f749c1b8acd73a..bd94563b94238b21cdb3eccf86920cd4e8400b69 100644 (file)
@@ -1,5 +1,9 @@
 <script setup lang="ts">
        import { RouterLink, RouterView } from "vue-router"
+
+       // Authentication
+       import { useAuthStore } from '@/stores/auth'
+       const auth = useAuthStore()
 </script>
 
 <template>
                                </div>
 
                                <div class="navbar-end">
-                                       <RouterLink to="/login" class="navbar-item">
+                                       <RouterLink to="/login" class="navbar-item" v-if="!auth.isLoggedIn">
                                                {{ $t("Login") }}
                                        </RouterLink>
+
+                                       <div v-else class="navbar-item has-dropdown is-hoverable">
+                                               <a class="navbar-link" href="#">
+                                                       {{ auth.user?.name }}
+                                               </a>
+
+                                               <div class="navbar-dropdown is-boxed">
+                                                       <a class="navbar-item" href="/logout">
+                                                               {{ $t("Log Out") }}
+                                                       </a>
+                                               </div>
+                                       </div>
                                </div>
                        </div>
                </div>
diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts
new file mode 100644 (file)
index 0000000..9eec094
--- /dev/null
@@ -0,0 +1,26 @@
+import { defineStore } from 'pinia'
+import type { User } from "@/types/User"
+
+interface AuthState {
+       user: User | null;
+       isLoggedIn: boolean;
+}
+
+export const useAuthStore = defineStore("auth", {
+       state: (): AuthState => ({
+               user: null,
+               isLoggedIn: false,
+       }),
+
+       actions: {
+               setUser(user: User) {
+                       this.user = user;
+                       this.isLoggedIn = true;
+               },
+
+               logout() {
+                       this.user = null;
+                       this.isLoggedIn = false;
+               },
+       },
+});
diff --git a/frontend/src/types/User.ts b/frontend/src/types/User.ts
new file mode 100644 (file)
index 0000000..2ad0b64
--- /dev/null
@@ -0,0 +1,8 @@
+/*
+       Defines our User object
+*/
+export interface User {
+  name: string;
+  email: string;
+  realname?: string;
+}
diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts
new file mode 100644 (file)
index 0000000..ba3291c
--- /dev/null
@@ -0,0 +1,63 @@
+import type { User } from "@/types/User"
+import { fetchWithAuth } from "@/utils/fetchWithAuth"
+
+interface AuthResponse {
+       access_token: string;
+       refresh_token?: string;
+}
+
+/*
+       Access Token
+*/
+
+function getAccessToken(): string | null {
+       return localStorage.getItem("token");
+}
+
+function setAccessToken(token: string): void {
+       localStorage.setItem("token", token)
+}
+
+/*
+       Takes username and password and logs in the user
+*/
+
+export async function login(username: string, password: string): Promise<User> {
+       const response = await fetch("/api/v1/auth/user", {
+               method : "POST",
+
+               // Headers
+               headers : {
+                       "Content-Type" : "application/x-www-form-urlencoded",
+               },
+
+               // Body
+               body : new URLSearchParams({
+                       username : username,
+                       password : password,
+               }),
+       });
+       if (!response.ok) {
+               throw new Error("Invalid username or password");
+       }
+
+       // Parse the response
+       const data: AuthResponse = await response.json();
+
+       // Store the access token
+       setAccessToken(data.access_token);
+
+       // Fetch the logged in user
+       return await fetchCurrentUser();
+}
+
+/*
+       Returns the currently logged in user
+*/
+export async function fetchCurrentUser(): Promise<User> {
+       const res = await fetchWithAuth("/api/v1/auth/whoami");
+       if (!res.ok)
+               throw new Error("Failed to load user");
+
+       return res.json();
+}
diff --git a/frontend/src/utils/fetchWithAuth.ts b/frontend/src/utils/fetchWithAuth.ts
new file mode 100644 (file)
index 0000000..0824bb6
--- /dev/null
@@ -0,0 +1,57 @@
+import { isTokenExpired } from './jwt'
+
+interface RefreshResponse {
+  access_token: string;
+  refresh_token?: string;
+}
+
+async function refreshToken(): Promise<boolean> {
+       try {
+               const response = await fetch("/api/v1/auth/refresh", {
+                       method: "POST",
+                       credentials: "include",
+               });
+               if (!response.ok)
+                       return false;
+
+               // Parse the response
+               const data: RefreshResponse = await response.json();
+
+               // Store the new access token
+       localStorage.setItem("token", data.access_token);
+
+               return true;
+
+       } catch {
+               return false;
+       }
+}
+
+export async function fetchWithAuth(input: RequestInfo, init?: RequestInit): Promise<Response> {
+       let token = localStorage.getItem("token");
+
+       if (!token || isTokenExpired(token)) {
+               const refreshed = await refreshToken();
+               if (!refreshed) {
+                       // no valid token and refresh failed — maybe logout user here or throw
+                       throw new Error('Not authenticated');
+               }
+
+               // Check if we have received a token
+               token = localStorage.getItem("token");
+               if (!token)
+                       throw new Error('No access token after refresh');
+       }
+
+       const authHeaders = {
+               ...(init?.headers || {}),
+               Authorization: `Bearer ${token}`,
+       };
+
+       const fetchInit = {
+               ...init,
+               headers: authHeaders,
+       };
+
+       return fetch(input, fetchInit);
+}
diff --git a/frontend/src/utils/jwt.ts b/frontend/src/utils/jwt.ts
new file mode 100644 (file)
index 0000000..952e6af
--- /dev/null
@@ -0,0 +1,38 @@
+/*
+       This function parses a JWT
+*/
+export function parseJwt<T = Record<string, any>>(token: string): T | null {
+       try {
+               const base64Url = token.split(".")[1];
+               if (!base64Url)
+                       return null;
+
+               const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
+
+               const jsonPayload = decodeURIComponent(
+                       atob(base64)
+                               .split("")
+                               .map(c => "%" + c.charCodeAt(0).toString(16).padStart(2, "0"))
+                               .join("")
+               );
+
+               return JSON.parse(jsonPayload) as T;
+       } catch {
+               return null;
+       }
+}
+
+export function isTokenExpired(token: string, offsetSeconds = 60): boolean {
+       // Parse the token
+       const payload = parseJwt(token);
+
+       // Is there is no expiry field, we assume the token has expired
+       if (!payload?.exp)
+               return true;
+
+       // Get the current time
+       const now = Math.floor(Date.now() / 1000);
+
+       // Return true if the token has expired
+       return payload.exp < now + offsetSeconds;
+}
index 69395ff8606973e3138aa3ca80f493099601742d..d1e5cf3465c3696d1ebb4823618f779d3548a379 100644 (file)
@@ -1,11 +1,27 @@
 <script setup lang="ts">
-       import { ref } from "vue"
-       import { useI18n } from "vue-i18n"
+       import { ref } from "vue";
 
+       // Translation
+       import { useI18n } from "vue-i18n"
        const { t } = useI18n()
 
+       // Router
+       import { useRouter } from "vue-router"
+       const router = useRouter()
+
+       // Import types
+       import type { User } from "@/types/User"
+
+       // Import utils
+       import { login } from "@/utils/auth"
+
+       // Import components
        import Notification from "../components/Notification.vue"
 
+       // Authentication Store
+       import { useAuthStore } from '@/stores/auth';
+       const auth = useAuthStore();
+
        // Error string shown to the user in case something went wrong
        const error = ref<string | null>(null)
 
        const username = ref<string>("")
        const password = ref<string>("")
 
-       async function login() {
+       async function submit() {
                // Reset the error
                error.value = null
 
                try {
-                       const response = await fetch("/api/v1/auth/user", {
-                               method : "POST",
-
-                               // Headers
-                               headers : {
-                                       "Content-Type" : "application/x-www-form-urlencoded",
-                               },
-
-                               // Body
-                               body : new URLSearchParams({
-                                       username: username.value,
-                                       password: password.value,
-                               }),
-                       })
-
-                       if (!response.ok) {
-                               throw new Error(t('Invalid username or password'))
-                       }
-
-                       const data = await response.json() as { access_token: string }
+                       // Perform login
+                       const user: User = await login(username.value, password.value);
+                       if (user)
+                               auth.setUser(user);
 
-                       localStorage.setItem('token', data.access_token)
-                       alert(t('Login successful!'))
-                       // TODO: router.push('/dashboard') or similar
+                       // Redirect back to the index page
+                       router.push("/")
 
                // Catch any errors
                } catch (err: unknown) {
@@ -61,7 +60,7 @@
                                                {{ error }}
                                        </Notification>
 
-                                       <form @submit.prevent="login">
+                                       <form @submit.prevent="submit">
                                                <div class="field">
                                                        <p class="control has-icons-left">
                                                                <input class="input" type="text" v-model="username"