feature/profile-page #71

Merged
AfonsoCMSousa merged 10 commits from feature/profile-page into develop 2025-12-23 21:49:21 +00:00
7 changed files with 305 additions and 93 deletions
Showing only changes of commit d1dd2c00f6 - Show all commits
@@ -24,6 +24,9 @@
<NavigationMenuLink> <NavigationMenuLink>
<a @click.prevent="logoutClickHandler">Logout</a> <a @click.prevent="logoutClickHandler">Logout</a>
</NavigationMenuLink> </NavigationMenuLink>
<NavigationMenuLink>
<RouterLink to="/user">Profile</RouterLink>
</NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
</NavigationMenuList> </NavigationMenuList>
</NavigationMenu> </NavigationMenu>
+4
View File
@@ -1,6 +1,7 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { io } from 'socket.io-client' import { io } from 'socket.io-client'
import { useAuthStore } from '@/stores/auth'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
@@ -20,4 +21,7 @@ app.provide('apiBaseURL', `http://${apiDomain}/api`)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
const authStore = useAuthStore()
authStore.initAuth()
app.mount('#app') app.mount('#app')
+58 -53
View File
@@ -1,55 +1,55 @@
<template> <template>
<div class="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8"> <div class="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<div class="w-full max-w-md space-y-8"> <div class="w-full max-w-md space-y-8">
<div> <div>
<h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900"> <h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
Sign in to your account Sign in to your account
</h2> </h2>
<p class="mt-2 text-center text-sm text-gray-600"> <p class="mt-2 text-center text-sm text-gray-600">
Enter your credentials to access your account Enter your credentials to access your account
</p> </p>
</div> </div>
<form class="mt-8 space-y-6" @submit.prevent="handleSubmit"> <form class="mt-8 space-y-6" @submit.prevent="handleSubmit">
<div class="space-y-4 rounded-md shadow-sm"> <div class="space-y-4 rounded-md shadow-sm">
<div> <div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1"> <label for="email" class="block text-sm font-medium text-gray-700 mb-1">
Email address Email address
</label> </label>
<Input id="email" v-model="formData.email" type="email" autocomplete="email" required <Input id="email" v-model="formData.email" type="email" autocomplete="email" required
placeholder="[email protected]" /> placeholder="[email protected]" :disabled="isLoading" />
</div> </div>
<div> <div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1"> <label for="password" class="block text-sm font-medium text-gray-700 mb-1">
Password Password
</label> </label>
<Input id="password" v-model="formData.password" type="password" autocomplete="current-password" <Input id="password" v-model="formData.password" type="password" autocomplete="current-password" required
required placeholder="••••••••" /> placeholder="••••••••" :disabled="isLoading" />
</div> </div>
</div>
<div>
<Button type="submit" class="w-full"> Sign in </Button>
</div>
<div class="text-center text-sm">
<span class="text-gray-600">Don't have an account? </span>
<a href="#" class="font-medium text-blue-600 hover:text-blue-500">
Sign up
</a>
</div>
</form>
</div> </div>
<div>
<Button type="submit" class="w-full" :disabled="isLoading">
{{ isLoading ? 'Signing in...' : 'Sign in' }}
</Button>
</div>
<div class="text-center text-sm">
<span class="text-gray-600">Don't have an account? </span>
<a href="#" class="font-medium text-blue-600 hover:text-blue-500">
Sign up
</a>
</div>
</form>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@@ -58,22 +58,27 @@ const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
const formData = ref({ const formData = ref({
email: 'pa@mail.pt', email: 'pa@mail.pt',
password: '123' password: '123'
}) })
const isLoading = ref(false)
const handleSubmit = async () => { const handleSubmit = async () => {
console.log(formData, authStore) isLoading.value = true
toast.promise(authStore.login(formData.value), {
loading: 'Calling API',
success: (data) => {
return `Login Sucessfull - ${data?.name}`
},
error: (data) => `[API] Error - ${data?.response?.data?.message}`
})
try {
const user = await authStore.login(formData.value)
router.push('/') toast.success(`Login Successful - Welcome ${user?.name}!`)
setTimeout(() => {
router.push('/')
}, 500)
} catch (error) {
toast.error(`Login Failed - ${error?.response?.data?.message || error.message}`)
} finally {
isLoading.value = false
}
} }
</script> </script>
+143 -16
View File
@@ -2,36 +2,163 @@
<div class="profile-page"> <div class="profile-page">
<h1>My Profile</h1> <h1>My Profile</h1>
<div v-if="userStore.user"> <!-- Loading State -->
<p><strong>Name:</strong> {{ userStore.user.name }}</p> <div v-if="loading" class="loading">
<p><strong>Email:</strong> {{ userStore.user.email }}</p> <p>Loading your profile...</p>
<p><strong>Joined:</strong> {{ new Date(userStore.user.created_at).toLocaleString() }}</p>
</div> </div>
<div v-else> <!-- Error State -->
<p>Loading... <b> 読み込み中 </b></p> <div v-else-if="error" class="error">
<p>Error: {{ error }}</p>
<button @click="retry">Retry</button>
</div>
<!-- User Data from Auth Store (preferred) -->
<div v-else-if="authStore.currentUser" class="user-info">
<div class="info-item">
<strong>Name:</strong> {{ authStore.currentUser.name }}
</div>
<div class="info-item">
<strong>Email:</strong> {{ authStore.currentUser.email }}
</div>
<div class="info-item">
<strong>Nickname:</strong> {{ authStore.currentUser.nickname }}
</div>
<div class="info-item">
<strong>Coins Balance:</strong> {{ authStore.currentUser.coins_balance }}
</div>
<div class="info-item">
<strong>Joined:</strong> {{ formatDate(authStore.currentUser.created_at) }}
</div>
</div>
<!-- No Data State -->
<div v-else class="no-data">
<p> No user data or not logged in. Please log in to view your profile. </p>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { useUserStore } from '@/stores/user' import { ref, onMounted } from 'vue'
import { onMounted } from 'vue' import { useAuthStore } from '@/stores/auth'
import { useAPIStore } from '@/stores/api'
import { useRouter } from 'vue-router'
const userStore = useUserStore() const authStore = useAuthStore()
const apiStore = useAPIStore()
const router = useRouter()
onMounted(() => { const loading = ref(false)
if (!userStore.user) { const error = ref(null)
userStore.fetchUser()
const formatDate = (dateString) => {
return new Date(dateString).toLocaleString()
}
const fetchProfile = async () => {
loading.value = true
error.value = null
try {
const response = await apiStore.getAuthUser()
authStore.currentUser = response.data
} catch (err) {
error.value = err.response?.data?.message || 'Failed to fetch user profile'
if (err.response?.status === 401) {
await authStore.logout()
router.push('/login')
}
} finally {
loading.value = false
}
}
const retry = () => {
fetchProfile()
}
onMounted(async () => {
// Redirect to login if not authenticated
if (!authStore.isLoggedIn) {
router.push('/login')
return
}
// If we already have user data from login, no need to fetch again
if (!authStore.currentUser) {
await fetchProfile()
} }
}) })
</script> </script>
<style> <style scoped>
.profile-page { .profile-page {
max-width: 600px; max-width: 600px;
margin: auto; margin: 2rem auto;
padding: 2rem;
}
h1 {
margin-bottom: 2rem;
color: #333;
font-size: 2rem;
}
.user-info {
background: #f5f5f5;
padding: 1.5rem;
border-radius: 8px;
}
.info-item {
margin-bottom: 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid #ddd;
display: flex;
gap: 1rem;
}
.info-item:last-child {
border-bottom: none;
}
.info-item strong {
min-width: 140px;
color: #555;
}
.loading, .error, .no-data {
text-align: center;
padding: 2rem;
}
.error {
color: #d32f2f;
background: #ffebee;
padding: 1.5rem;
border-radius: 8px;
}
.no-data {
background: #fff3e0;
padding: 1.5rem;
border-radius: 8px;
}
button {
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background: #1976d2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover {
background: #1565c0;
} }
</style> </style>
+12
View File
@@ -1,3 +1,4 @@
// src/stores/api.js
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import axios from 'axios' import axios from 'axios'
import { inject, ref } from 'vue' import { inject, ref } from 'vue'
@@ -21,7 +22,10 @@ export const useAPIStore = defineStore('api', () => {
const response = await axios.post(`${API_BASE_URL}/login`, credentials) const response = await axios.post(`${API_BASE_URL}/login`, credentials)
token.value = response.data.token token.value = response.data.token
axios.defaults.headers.common['Authorization'] = `Bearer ${token.value}` axios.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
return response
} }
const postLogout = async () => { const postLogout = async () => {
await axios.post(`${API_BASE_URL}/logout`) await axios.post(`${API_BASE_URL}/logout`)
token.value = undefined token.value = undefined
@@ -53,11 +57,19 @@ export const useAPIStore = defineStore('api', () => {
return axios.get(`${API_BASE_URL}/games?${queryParams}`) return axios.get(`${API_BASE_URL}/games?${queryParams}`)
} }
const initToken = (storedToken) => {
if (storedToken) {
token.value = storedToken
axios.defaults.headers.common['Authorization'] = `Bearer ${storedToken}`
}
}
return { return {
postLogin, postLogin,
postLogout, postLogout,
getAuthUser, getAuthUser,
getGames, getGames,
gameQueryParameters, gameQueryParameters,
initToken,
} }
}) })
+52 -10
View File
@@ -1,6 +1,8 @@
// src/stores/auth.js
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useAPIStore } from './api' import { useAPIStore } from './api'
import axios from 'axios'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const apiStore = useAPIStore() const apiStore = useAPIStore()
@@ -20,8 +22,10 @@ export const useAuthStore = defineStore('auth', () => {
token.value = jwtToken token.value = jwtToken
if (jwtToken) { if (jwtToken) {
localStorage.setItem('token', jwtToken) localStorage.setItem('token', jwtToken)
axios.defaults.headers.common['Authorization'] = `Bearer ${jwtToken}`
} else { } else {
localStorage.removeItem('token') localStorage.removeItem('token')
delete axios.defaults.headers.common['Authorization']
} }
} }
@@ -30,20 +34,57 @@ export const useAuthStore = defineStore('auth', () => {
} }
const login = async (credentials) => { const login = async (credentials) => {
await apiStore.postLogin(credentials) try {
const response = await apiStore.getAuthUser() // postLogin already sets the token and axios headers, and returns the response
const jwtToken = response const loginResponse = await apiStore.postLogin(credentials)
const user = response.data
setToken(jwtToken) // Extract the token from the response
currentUser.value = response.data const jwtToken = loginResponse.data.token
return response.data
// Store token in auth store (for localStorage)
setToken(jwtToken)
// Use the user data from login response (it's already there!)
currentUser.value = loginResponse.data.user
return currentUser.value
} catch (error) {
// Clear everything on login failure
setToken(null)
currentUser.value = undefined
throw error
}
} }
const logout = async () => { const logout = async () => {
await apiStore.postLogout() try {
currentUser.value = undefined await apiStore.postLogout()
setToken(null) } catch (error) {
console.error('Logout API call failed:', error)
} finally {
currentUser.value = undefined
setToken(null)
}
}
// Initialize auth from localStorage on app start
const initAuth = async () => {
if (token.value) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
apiStore.initToken(token.value)
// Try to fetch current user if we don't have it
if (!currentUser.value) {
try {
const userResponse = await apiStore.getAuthUser()
currentUser.value = userResponse.data
} catch (error) {
console.error('Failed to restore user session:', error)
// Clear invalid token
setToken(null)
}
}
}
} }
return { return {
@@ -55,5 +96,6 @@ export const useAuthStore = defineStore('auth', () => {
getToken, getToken,
login, login,
logout, logout,
initAuth,
} }
}) })
+33 -14
View File
@@ -1,39 +1,58 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import axios from 'axios' import axios from 'axios'
import { useAuthStore } from './auth'
export const useUserStore = defineStore('user', { export const useUserStore = defineStore('user', {
state: () => ({ state: () => ({
user: null, user: null,
token: localStorage.getItem('token') || null, loading: false,
error: null
}), }),
getters: {
currentUser: (state) => state.user
},
actions: { actions: {
async fetchUser() { async fetchUserProfile() {
if (!this.token) return const authStore = useAuthStore()
const token = authStore.getToken()
if (!token) {
this.error = 'No authentication token found'
throw new Error('No authentication token found')
}
this.loading = true
this.error = null
try { try {
const response = await axios.get('/api/users/me', { const response = await axios.get('/api/users/me', {
headers: { headers: {
Authorization: `Bearer ${this.token}` Authorization: `Bearer ${token}`,
Accept: 'application/json'
} }
}) })
this.user = response.data this.user = response.data
return response.data
} catch (error) { } catch (error) {
console.error("Failed to fetch user") this.error = error.response?.data?.message || 'Failed to fetch user profile'
if (error.response?.status === 401) {
authStore.logout()
}
throw error
} finally {
this.loading = false
} }
}, },
setToken(token) { clearUser() {
this.token = token
localStorage.setItem('token', token)
},
logout() {
this.token = null
this.user = null this.user = null
localStorage.removeItem('token') this.error = null
this.loading = false
} }
} }
}) })