Merge pull request #35 from StewKI/feature-client-with-working-auth
Feature client with working auth
This commit is contained in:
55
front/src/components/AuthStatus.vue
Normal file
55
front/src/components/AuthStatus.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="auth-status">
|
||||
<template v-if="isLoading">
|
||||
<span class="loading">Loading...</span>
|
||||
</template>
|
||||
<template v-else-if="isAuthenticated">
|
||||
<span class="username">{{ user?.username ?? user?.email }}</span>
|
||||
<button @click="logout" class="logout">Logout</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot>
|
||||
<!-- Default: show nothing or provide links in parent -->
|
||||
</slot>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const store = useAuthStore()
|
||||
const user = computed(() => store.user)
|
||||
const isAuthenticated = computed(() => store.isAuthenticated)
|
||||
const isLoading = computed(() => store.isLoading)
|
||||
|
||||
function logout() {
|
||||
store.logout()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// ensure store is hydrated; safe to call multiple times
|
||||
void store.initialize()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.username {
|
||||
font-weight: 600;
|
||||
}
|
||||
.logout {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ccc;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.loading {
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,7 @@ const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
const auth = useAuthStore()
|
||||
auth.initialize()
|
||||
await auth.initialize()
|
||||
|
||||
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> {
|
||||
const auth = useAuthStore()
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'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<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 = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
get: <T = any>(url: string) => request<T>('GET', url),
|
||||
post: <T = any>(url: string, body?: any) => request<T>('POST', url, body),
|
||||
put: <T = any>(url: string, body?: any) => request<T>('PUT', url, body),
|
||||
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 { 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<AuthResponse> {
|
||||
// TODO: return api.post<AuthResponse>('/auth/login', credentials)
|
||||
throw new Error('Not implemented')
|
||||
async login(credentials: LoginCredentials): Promise<AuthResponse> {
|
||||
const raw = await api.post<any>('/api/User/login', credentials)
|
||||
return normalizeAuthResponse(raw)
|
||||
},
|
||||
|
||||
async signup(_credentials: SignupCredentials): Promise<AuthResponse> {
|
||||
// TODO: return api.post<AuthResponse>('/auth/signup', credentials)
|
||||
throw new Error('Not implemented')
|
||||
async refreshLogin(refreshToken: string): Promise<AuthResponse> {
|
||||
const raw = await api.post<any>('/api/User/refresh-login', { refreshToken })
|
||||
return normalizeAuthResponse(raw)
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
// TODO: return api.post<void>('/auth/logout')
|
||||
throw new Error('Not implemented')
|
||||
async signup(credentials: SignupCredentials): Promise<AuthResponse> {
|
||||
const raw = await api.post<any>('/api/User/signup', credentials)
|
||||
return normalizeAuthResponse(raw)
|
||||
},
|
||||
|
||||
async getCurrentUser(): Promise<User> {
|
||||
// TODO: return api.get<User>('/auth/me')
|
||||
throw new Error('Not implemented')
|
||||
async logout(refreshToken: string): Promise<void> {
|
||||
return api.delete<void>('/api/User/logout', {refreshToken: refreshToken})
|
||||
},
|
||||
|
||||
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 { 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<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)
|
||||
|
||||
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<void> | 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<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) {
|
||||
// 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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,6 +15,6 @@ export interface SignupCredentials {
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User
|
||||
token: string
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user