SyntaxSquad/DADProject#9 feat: User info dysplayed in /users/
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user