From 7cd7d4899c8301cb58b8cdb3bc818df4c5692923 Mon Sep 17 00:00:00 2001 From: Veljko Tosic Date: Sun, 15 Feb 2026 23:01:27 +0100 Subject: [PATCH] Front --- front/src/main.ts | 2 +- front/src/services/api.ts | 99 +++++++++++++---- front/src/services/authService.ts | 43 +++++--- front/src/stores/auth.ts | 172 ++++++++++++++++++++++++------ front/src/types/index.ts | 4 +- front/vite.config.ts | 12 ++- 6 files changed, 261 insertions(+), 71 deletions(-) diff --git a/front/src/main.ts b/front/src/main.ts index e3a679b..a133b77 100644 --- a/front/src/main.ts +++ b/front/src/main.ts @@ -13,7 +13,7 @@ const pinia = createPinia() app.use(pinia) const auth = useAuthStore() -auth.initialize() +await auth.initialize() app.use(router) diff --git a/front/src/services/api.ts b/front/src/services/api.ts index b3470e0..cc3068e 100644 --- a/front/src/services/api.ts +++ b/front/src/services/api.ts @@ -1,37 +1,90 @@ -import { useAuthStore } from '@/stores/auth' +// A small fetch-based HTTP client with automatic token attach and refresh-on-401. +// This avoids circular imports by reading/writing tokens directly from localStorage. -const BASE_URL = import.meta.env.VITE_API_URL ?? '/api' +const ACCESS_TOKEN = 'auth_token' +const REFRESH_TOKEN = 'refresh_token' -async function request(method: string, path: string, body?: unknown): Promise { - const auth = useAuthStore() - - const headers: Record = { - 'Content-Type': 'application/json', +async function parseJsonOrThrow(res: Response) { + const text = await res.text() + try { + return text ? JSON.parse(text) : undefined + } catch (e) { + // non-JSON response + return text } +} - if (auth.token) { - headers['Authorization'] = `Bearer ${auth.token}` - } +async function refreshTokens(): Promise<{ accessToken?: string; refreshToken?: string } | null> { + const refreshToken = localStorage.getItem(REFRESH_TOKEN) + if (!refreshToken) return null - const res = await fetch(`${BASE_URL}${path}`, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, + const res = await fetch('/api/User/refresh-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }), }) - if (!res.ok) { - const text = await res.text() - throw new Error(text || `Request failed: ${res.status}`) + if (!res.ok) return null + const data = await parseJsonOrThrow(res) + const accessToken = data?.accessToken ?? data?.token ?? null + const newRefresh = data?.refreshToken ?? null + if (accessToken) { + localStorage.setItem(ACCESS_TOKEN, accessToken) + } + if (newRefresh) { + localStorage.setItem(REFRESH_TOKEN, newRefresh) + } + return { accessToken, refreshToken: newRefresh } +} + +async function request(method: string, url: string, body?: any, attemptRefresh = true): Promise { + const headers: Record = {} + headers['Content-Type'] = 'application/json' + + const token = localStorage.getItem(ACCESS_TOKEN) + if (token) headers['Authorization'] = `Bearer ${token}` + + const payload = body ?? {} + + const res = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(payload) : null, + }) + + if (res.status === 401 && attemptRefresh) { + // try refreshing tokens once + const refreshed = await refreshTokens() + if (refreshed && refreshed.accessToken) { + // retry original request with new token + const retryHeaders: Record = {} + if (body !== undefined && body !== null) retryHeaders['Content-Type'] = 'application/json' + retryHeaders['Authorization'] = `Bearer ${refreshed.accessToken}` + + const retryRes = await fetch(url, { + method, + headers: retryHeaders, + body: body !== undefined && body !== null ? JSON.stringify(body) : undefined, + }) + if (!retryRes.ok) { + const errorBody = await parseJsonOrThrow(retryRes) + throw Object.assign(new Error(retryRes.statusText || 'Request failed'), { status: retryRes.status, body: errorBody }) + } + return await parseJsonOrThrow(retryRes) + } } - if (res.status === 204) return undefined as T + if (!res.ok) { + const errorBody = await parseJsonOrThrow(res) + throw Object.assign(new Error(res.statusText || 'Request failed'), { status: res.status, body: errorBody }) + } - return res.json() as Promise + return await parseJsonOrThrow(res) } export const api = { - get: (path: string) => request('GET', path), - post: (path: string, body?: unknown) => request('POST', path, body), - put: (path: string, body?: unknown) => request('PUT', path, body), - delete: (path: string) => request('DELETE', path), + get: (url: string) => request('GET', url), + post: (url: string, body?: any) => request('POST', url, body), + put: (url: string, body?: any) => request('PUT', url, body), + delete: (url: string, body?: any) => request('DELETE', url, body), } diff --git a/front/src/services/authService.ts b/front/src/services/authService.ts index 3d3e823..58969bd 100644 --- a/front/src/services/authService.ts +++ b/front/src/services/authService.ts @@ -1,26 +1,41 @@ import type { AuthResponse, LoginCredentials, SignupCredentials, User } from '@/types' +import { api } from './api' -// TODO: Wire up to real API endpoints via `api` helper -// import { api } from './api' +function normalizeAuthResponse(raw: any): AuthResponse { + const accessToken = raw?.accessToken ?? raw?.AccessToken ?? raw?.token ?? raw?.Token ?? raw?.access_token ?? null + const refreshToken = raw?.refreshToken ?? raw?.RefreshToken ?? raw?.refresh_token ?? null + return { accessToken, refreshToken } +} export const authService = { - async login(_credentials: LoginCredentials): Promise { - // TODO: return api.post('/auth/login', credentials) - throw new Error('Not implemented') + async login(credentials: LoginCredentials): Promise { + const raw = await api.post('/api/User/login', credentials) + return normalizeAuthResponse(raw) }, - async signup(_credentials: SignupCredentials): Promise { - // TODO: return api.post('/auth/signup', credentials) - throw new Error('Not implemented') + async refreshLogin(refreshToken: string): Promise { + const raw = await api.post('/api/User/refresh-login', { refreshToken }) + return normalizeAuthResponse(raw) }, - async logout(): Promise { - // TODO: return api.post('/auth/logout') - throw new Error('Not implemented') + async signup(credentials: SignupCredentials): Promise { + const raw = await api.post('/api/User/signup', credentials) + return normalizeAuthResponse(raw) }, - async getCurrentUser(): Promise { - // TODO: return api.get('/auth/me') - throw new Error('Not implemented') + async logout(refreshToken: string): Promise { + return api.delete('/api/User/logout', {refreshToken: refreshToken}) + }, + + async logoutAll(): Promise { + return api.delete('/api/User/logout-all') + }, + + async getMe() : Promise { + const raw = await api.get('/api/User/me') + // backend User may have fields like userName / UserName and email / Email + const username = raw?.userName ?? raw?.UserName ?? raw?.username ?? raw?.name ?? '' + const email = raw?.email ?? raw?.Email ?? '' + return { username, email } }, } diff --git a/front/src/stores/auth.ts b/front/src/stores/auth.ts index 0b686e9..08e1dc7 100644 --- a/front/src/stores/auth.ts +++ b/front/src/stores/auth.ts @@ -1,53 +1,165 @@ import { ref, computed } from 'vue' import { defineStore } from 'pinia' -import type { User, LoginCredentials, SignupCredentials } from '@/types' +import type { User, LoginCredentials, SignupCredentials, AuthResponse } from '@/types' +import { authService } from '@/services/authService' -const TOKEN_KEY = 'auth_token' +const ACCESS_TOKEN = 'auth_token' +const REFRESH_TOKEN = 'refresh_token' export const useAuthStore = defineStore('auth', () => { const user = ref(null) - const token = ref(null) + const accessToken = ref(null) + const isLoading = ref(false) + const error = ref(null) const isAuthenticated = computed(() => !!user.value) - function initialize() { - const saved = localStorage.getItem(TOKEN_KEY) - if (saved) { - token.value = saved - // TODO: call authService.getCurrentUser() to validate token & hydrate user - user.value = { username: 'User', email: 'user@example.com' } + // single-flight promise for refresh to avoid concurrent refresh requests + let refreshPromise: Promise | null = null + + function setTokens(access: string | null, refresh: string | null) { + accessToken.value = access + if (access) { + localStorage.setItem(ACCESS_TOKEN, access) + } else { + localStorage.removeItem(ACCESS_TOKEN) } + + if (refresh) { + localStorage.setItem(REFRESH_TOKEN, refresh) + } else if (refresh === null) { + // explicit null means remove + localStorage.removeItem(REFRESH_TOKEN) + } + } + + async function tryRefresh(): Promise { + // if a refresh is already in progress, return that promise + if (refreshPromise) return refreshPromise + + const refreshToken = localStorage.getItem(REFRESH_TOKEN) + if (!refreshToken) { + // nothing to do + throw new Error('No refresh token') + } + + refreshPromise = (async () => { + try { + const res = await authService.refreshLogin(refreshToken) + // API expected to return { accessToken, refreshToken } + setTokens(res.accessToken ?? null, res.refreshToken ?? null) + } finally { + // clear so subsequent calls can create a new promise + refreshPromise = null + } + })() + + return refreshPromise + } + + async function initialize() { + isLoading.value = true + error.value = null + const saved = localStorage.getItem(ACCESS_TOKEN) + const savedRefresh = localStorage.getItem(REFRESH_TOKEN) + + if (saved) { + accessToken.value = saved + try { + user.value = await authService.getMe() + isLoading.value = false + return + } catch (e) { + // token might be expired; try refresh if we have refresh token + if (savedRefresh) { + try { + await tryRefresh() + user.value = await authService.getMe() + isLoading.value = false + return + } catch (err) { + // refresh failed - fallthrough to clearing auth + console.warn('Token refresh failed during initialize', err) + } + } + } + } + + // not authenticated or refresh failed + setTokens(null, null) + user.value = null + isLoading.value = false } async function login(credentials: LoginCredentials) { - // TODO: const res = await authService.login(credentials) - // Mock successful response for now - const res = { - user: { username: credentials.email.split('@')[0] ?? credentials.email, email: credentials.email }, - token: 'mock-jwt-token', + isLoading.value = true + error.value = null + try { + const res: AuthResponse = await authService.login(credentials) + // expect AuthResponse to have accessToken and refreshToken + setTokens(res.accessToken ?? null, res.refreshToken ?? null) + + try { + user.value = await authService.getMe() + } catch (e) { + console.error('Logged in but failed to fetch user profile', e) + // keep tokens but clear user + user.value = null + } + } catch (e: any) { + error.value = e?.message ?? 'Login failed' + throw e + } finally { + isLoading.value = false } - user.value = res.user - token.value = res.token - localStorage.setItem(TOKEN_KEY, res.token) } async function signup(credentials: SignupCredentials) { - // TODO: const res = await authService.signup(credentials) - // Mock successful response for now - const res = { - user: { username: credentials.username, email: credentials.email }, - token: 'mock-jwt-token', + isLoading.value = true + error.value = null + + try { + const res: AuthResponse = await authService.signup(credentials) + setTokens(res.accessToken ?? null, res.refreshToken ?? null) + try { + user.value = await authService.getMe() + } catch (e) { + // keep tokens but no profile + user.value = null + } + } catch (e: any) { + error.value = e?.message ?? 'Signup failed' + throw e + } finally { + isLoading.value = false } - user.value = res.user - token.value = res.token - localStorage.setItem(TOKEN_KEY, res.token) } - function logout() { - user.value = null - token.value = null - localStorage.removeItem(TOKEN_KEY) + async function logout(allDevices = false) { + isLoading.value = true + error.value = null + try { + if (allDevices) await authService.logoutAll() + else await authService.logout(localStorage.getItem(REFRESH_TOKEN)!) + } catch (e) { + // ignore network errors on logout + console.warn('Logout request failed', e) + } finally { + setTokens(null, null) + user.value = null + isLoading.value = false + } } - return { user, token, isAuthenticated, initialize, login, signup, logout } + return { + user, + accessToken, + isAuthenticated, + isLoading, + error, + initialize, + login, + signup, + logout, + } }) diff --git a/front/src/types/index.ts b/front/src/types/index.ts index a032564..5cdb2ee 100644 --- a/front/src/types/index.ts +++ b/front/src/types/index.ts @@ -15,6 +15,6 @@ export interface SignupCredentials { } export interface AuthResponse { - user: User - token: string + accessToken: string + refreshToken: string } diff --git a/front/vite.config.ts b/front/vite.config.ts index ae9cae0..1a42daa 100644 --- a/front/vite.config.ts +++ b/front/vite.config.ts @@ -4,17 +4,27 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import vueDevTools from 'vite-plugin-vue-devtools' -// https://vite.dev/config/ export default defineConfig({ plugins: [ vue(), vueDevTools(), ], + resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, }, + + server: { + proxy: { + '/api': { + target: 'http://localhost:5266', + changeOrigin: true + } + } + }, + css: { preprocessorOptions: { scss: {