This commit is contained in:
Veljko Tosic
2026-02-15 23:01:27 +01:00
parent 3463ba1e6d
commit 7cd7d4899c
6 changed files with 261 additions and 71 deletions

View File

@@ -13,7 +13,7 @@ const pinia = createPinia()
app.use(pinia) app.use(pinia)
const auth = useAuthStore() const auth = useAuthStore()
auth.initialize() await auth.initialize()
app.use(router) app.use(router)

View File

@@ -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<T>(method: string, path: string, body?: unknown): Promise<T> { async function parseJsonOrThrow(res: Response) {
const auth = useAuthStore() const text = await res.text()
try {
const headers: Record<string, string> = { return text ? JSON.parse(text) : undefined
'Content-Type': 'application/json', } catch (e) {
// non-JSON response
return text
} }
}
if (auth.token) { async function refreshTokens(): Promise<{ accessToken?: string; refreshToken?: string } | null> {
headers['Authorization'] = `Bearer ${auth.token}` const refreshToken = localStorage.getItem(REFRESH_TOKEN)
} if (!refreshToken) return null
const res = await fetch(`${BASE_URL}${path}`, { const res = await fetch('/api/User/refresh-login', {
method, method: 'POST',
headers, headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined, body: JSON.stringify({ refreshToken }),
}) })
if (!res.ok) { if (!res.ok) return null
const text = await res.text() const data = await parseJsonOrThrow(res)
throw new Error(text || `Request failed: ${res.status}`) 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<T = any>(method: string, url: string, body?: any, attemptRefresh = true): Promise<T> {
const headers: Record<string, string> = {}
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<string, string> = {}
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<T> return await parseJsonOrThrow(res)
} }
export const api = { export const api = {
get: <T>(path: string) => request<T>('GET', path), get: <T = any>(url: string) => request<T>('GET', url),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body), post: <T = any>(url: string, body?: any) => request<T>('POST', url, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body), put: <T = any>(url: string, body?: any) => request<T>('PUT', url, body),
delete: <T>(path: string) => request<T>('DELETE', path), delete: <T = any>(url: string, body?: any) => request<T>('DELETE', url, body),
} }

View File

@@ -1,26 +1,41 @@
import type { AuthResponse, LoginCredentials, SignupCredentials, User } from '@/types' import type { AuthResponse, LoginCredentials, SignupCredentials, User } from '@/types'
import { api } from './api'
// TODO: Wire up to real API endpoints via `api` helper function normalizeAuthResponse(raw: any): AuthResponse {
// import { api } from './api' 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 = { export const authService = {
async login(_credentials: LoginCredentials): Promise<AuthResponse> { async login(credentials: LoginCredentials): Promise<AuthResponse> {
// TODO: return api.post<AuthResponse>('/auth/login', credentials) const raw = await api.post<any>('/api/User/login', credentials)
throw new Error('Not implemented') return normalizeAuthResponse(raw)
}, },
async signup(_credentials: SignupCredentials): Promise<AuthResponse> { async refreshLogin(refreshToken: string): Promise<AuthResponse> {
// TODO: return api.post<AuthResponse>('/auth/signup', credentials) const raw = await api.post<any>('/api/User/refresh-login', { refreshToken })
throw new Error('Not implemented') return normalizeAuthResponse(raw)
}, },
async logout(): Promise<void> { async signup(credentials: SignupCredentials): Promise<AuthResponse> {
// TODO: return api.post<void>('/auth/logout') const raw = await api.post<any>('/api/User/signup', credentials)
throw new Error('Not implemented') return normalizeAuthResponse(raw)
}, },
async getCurrentUser(): Promise<User> { async logout(refreshToken: string): Promise<void> {
// TODO: return api.get<User>('/auth/me') return api.delete<void>('/api/User/logout', {refreshToken: refreshToken})
throw new Error('Not implemented') },
async logoutAll(): Promise<void> {
return api.delete<void>('/api/User/logout-all')
},
async getMe() : Promise<User> {
const raw = await api.get<any>('/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 }
}, },
} }

View File

@@ -1,53 +1,165 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { defineStore } from 'pinia' 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', () => { export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null) const user = ref<User | null>(null)
const token = ref<string | null>(null) const accessToken = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value) const isAuthenticated = computed(() => !!user.value)
function initialize() { // single-flight promise for refresh to avoid concurrent refresh requests
const saved = localStorage.getItem(TOKEN_KEY) let refreshPromise: Promise<void> | null = null
if (saved) {
token.value = saved function setTokens(access: string | null, refresh: string | null) {
// TODO: call authService.getCurrentUser() to validate token & hydrate user accessToken.value = access
user.value = { username: 'User', email: 'user@example.com' } 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<void> {
// 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) { async function login(credentials: LoginCredentials) {
// TODO: const res = await authService.login(credentials) isLoading.value = true
// Mock successful response for now error.value = null
const res = { try {
user: { username: credentials.email.split('@')[0] ?? credentials.email, email: credentials.email }, const res: AuthResponse = await authService.login(credentials)
token: 'mock-jwt-token', // 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) { async function signup(credentials: SignupCredentials) {
// TODO: const res = await authService.signup(credentials) isLoading.value = true
// Mock successful response for now error.value = null
const res = {
user: { username: credentials.username, email: credentials.email }, try {
token: 'mock-jwt-token', 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() { async function logout(allDevices = false) {
user.value = null isLoading.value = true
token.value = null error.value = null
localStorage.removeItem(TOKEN_KEY) 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,
}
}) })

View File

@@ -15,6 +15,6 @@ export interface SignupCredentials {
} }
export interface AuthResponse { export interface AuthResponse {
user: User accessToken: string
token: string refreshToken: string
} }

View File

@@ -4,17 +4,27 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools' import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
vueDevTools(), vueDevTools(),
], ],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))
}, },
}, },
server: {
proxy: {
'/api': {
target: 'http://localhost:5266',
changeOrigin: true
}
}
},
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {