Front
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
token: 'mock-jwt-token',
|
|
||||||
}
|
|
||||||
user.value = res.user
|
|
||||||
token.value = res.token
|
|
||||||
localStorage.setItem(TOKEN_KEY, res.token)
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
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
|
user.value = null
|
||||||
token.value = null
|
}
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
} catch (e: any) {
|
||||||
|
error.value = e?.message ?? 'Signup failed'
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user, token, isAuthenticated, initialize, login, signup, logout }
|
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,
|
||||||
|
accessToken,
|
||||||
|
isAuthenticated,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
initialize,
|
||||||
|
login,
|
||||||
|
signup,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,6 @@ export interface SignupCredentials {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
user: User
|
accessToken: string
|
||||||
token: string
|
refreshToken: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user