<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>
--- /dev/null
+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;
+ },
+ },
+});
--- /dev/null
+/*
+ Defines our User object
+*/
+export interface User {
+ name: string;
+ email: string;
+ realname?: string;
+}
--- /dev/null
+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();
+}
--- /dev/null
+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);
+}
--- /dev/null
+/*
+ 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;
+}
<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) {
{{ 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"