integrate the user module
This commit is contained in:
@@ -102,7 +102,7 @@ class Config{
|
|||||||
* @type {string}
|
* @type {string}
|
||||||
* @memberof AccessManagementOptions
|
* @memberof AccessManagementOptions
|
||||||
*/
|
*/
|
||||||
salt : 'P@ssSal7y!!',
|
salt : 'P@ssSal7y!N@tur3!',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @class Cookie options, опции за бисквитките
|
* @class Cookie options, опции за бисквитките
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
|
import passport from 'passport';
|
||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
import MongoDBStore from 'connect-mongodb-session';
|
import MongoDBStore from 'connect-mongodb-session';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
@@ -57,6 +58,9 @@ class WebServer {
|
|||||||
// next();
|
// next();
|
||||||
// })
|
// })
|
||||||
|
|
||||||
|
xapp.use(passport.initialize());
|
||||||
|
xapp.use(passport.session());
|
||||||
|
|
||||||
xapp.use(express.json({ limit: '150mb' }));
|
xapp.use(express.json({ limit: '150mb' }));
|
||||||
xapp.use(express.urlencoded({ extended: false, limit: '150mb' }));
|
xapp.use(express.urlencoded({ extended: false, limit: '150mb' }));
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -4,6 +4,12 @@
|
|||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script>
|
||||||
//
|
export default {
|
||||||
|
async created() {
|
||||||
|
this.loadUser();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,48 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-app-bar color="primary" scroll-behavior="elevate" scroll-threshold="20">
|
<v-app-bar color="blue" scroll-behavior="elevate" scroll-threshold="20">
|
||||||
<!-- <v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer"></v-app-bar-nav-icon> -->
|
<!-- <v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer"></v-app-bar-nav-icon> -->
|
||||||
<v-app-bar-title>ProNature Administrative Console</v-app-bar-title>
|
<v-app-bar-title>ProNature Playground</v-app-bar-title>
|
||||||
<v-menu>
|
<v-btn to="/manage" icon="mdi-wrench-cog-outline" v-if="roles.editor"></v-btn>
|
||||||
|
<v-dialog max-width="400">
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-btn icon="mdi-plus" variant="text" v-bind="props"></v-btn>
|
<v-btn icon="mdi-account" variant="text" v-bind="props"></v-btn>
|
||||||
</template>
|
</template>
|
||||||
<v-list>
|
<v-card class="pa-3" :title="user? 'Profile' : 'Sign in' ">
|
||||||
<v-list-item to="/manage/game-objects/add">{{ l.createGameObject }}</v-list-item>
|
<Auth v-if="!user"></Auth>
|
||||||
<v-list-item to="/manage/scenarios/add">{{ l.createScenario }}</v-list-item>
|
<Profile v-else></Profile>
|
||||||
<v-list-item to="/manage/games/add">{{ l.createGame }}</v-list-item>
|
</v-card>
|
||||||
</v-list>
|
</v-dialog >
|
||||||
</v-menu>
|
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
|
|
||||||
<v-navigation-drawer class="bg-secondary" expand-on-hover rail>
|
|
||||||
<v-list>
|
|
||||||
<v-list-item prepend-avatar="/logo.webp" subtitle="Admin Console" title="ProNature"></v-list-item>
|
|
||||||
</v-list>
|
|
||||||
<v-divider></v-divider>
|
|
||||||
<v-list nav>
|
|
||||||
<v-list-item prepend-icon="mdi-database" to="/manage/game-objects/list" :title="l.gameObjects"></v-list-item>
|
|
||||||
<v-list-item prepend-icon="mdi-receipt-text-edit-outline" to="/manage/scenarios/list" :title="l.gameScenarios"></v-list-item>
|
|
||||||
<!-- <v-list-item prepend-icon="mdi-cogs" :title="l.gameRules"></v-list-item> -->
|
|
||||||
<v-divider></v-divider>
|
|
||||||
<v-list-item prepend-icon="mdi-controller" :title="l.games" to="/manage/games/list"></v-list-item>
|
|
||||||
<v-list-item prepend-icon="mdi-cog-play" :title="l.preview" to="/manage/preview/list"></v-list-item>
|
|
||||||
|
|
||||||
</v-list>
|
|
||||||
<v-divider></v-divider>
|
|
||||||
<v-list nav>
|
|
||||||
<v-list-item @click="toggleTheme" prepend-icon="mdi-theme-light-dark" :title="l.darkMode"></v-list-item>
|
|
||||||
<v-list-item prepend-icon="mdi-translate">
|
|
||||||
<v-btn-toggle density="compact" v-model="lang" rounded="xl" color="white" variant="outlined">
|
|
||||||
<v-btn size="small" value="bg">BG</v-btn>
|
|
||||||
<v-btn size="small" value="en">EN</v-btn>
|
|
||||||
</v-btn-toggle>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-navigation-drawer>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
|
import Profile from './User/Profile.vue';
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
|
||||||
function toggleTheme () {
|
function toggleTheme () {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<v-app-bar color="primary" scroll-behavior="elevate" scroll-threshold="20">
|
||||||
|
<!-- <v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer"></v-app-bar-nav-icon> -->
|
||||||
|
<v-app-bar-title>ProNature Games Workshop</v-app-bar-title>
|
||||||
|
<v-btn to="/" icon="mdi-seesaw" v-tooltip="'To playground'"></v-btn>
|
||||||
|
<v-menu>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn icon="mdi-plus" variant="text" v-bind="props"></v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item to="/manage/game-objects/add">{{ l.createGameObject }}</v-list-item>
|
||||||
|
<v-list-item to="/manage/scenarios/add">{{ l.createScenario }}</v-list-item>
|
||||||
|
<v-list-item to="/manage/games/add">{{ l.createGame }}</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</v-app-bar>
|
||||||
|
|
||||||
|
<v-navigation-drawer class="bg-secondary" expand-on-hover rail>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item prepend-avatar="/logo.webp" subtitle="Admin Console" title="ProNature"></v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-list nav>
|
||||||
|
<v-list-item prepend-icon="mdi-database" to="/manage/game-objects/list" :title="l.gameObjects"></v-list-item>
|
||||||
|
<v-list-item prepend-icon="mdi-receipt-text-edit-outline" to="/manage/scenarios/list" :title="l.gameScenarios"></v-list-item>
|
||||||
|
<!-- <v-list-item prepend-icon="mdi-cogs" :title="l.gameRules"></v-list-item> -->
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-list-item prepend-icon="mdi-controller" :title="l.games" to="/manage/games/list"></v-list-item>
|
||||||
|
<v-list-item prepend-icon="mdi-cog-play" :title="l.preview" to="/manage/preview/list"></v-list-item>
|
||||||
|
|
||||||
|
</v-list>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
<v-list nav>
|
||||||
|
<v-list-item @click="toggleTheme" prepend-icon="mdi-theme-light-dark" :title="l.darkMode"></v-list-item>
|
||||||
|
<v-list-item prepend-icon="mdi-translate">
|
||||||
|
<v-btn-toggle density="compact" v-model="lang" rounded="xl" color="white" variant="outlined">
|
||||||
|
<v-btn size="small" value="bg">BG</v-btn>
|
||||||
|
<v-btn size="small" value="en">EN</v-btn>
|
||||||
|
</v-btn-toggle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useTheme } from 'vuetify'
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
|
function toggleTheme () {
|
||||||
|
theme.global.name.value = theme.global.current.value.dark ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -35,13 +35,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
import { useAppStore } from '@/stores/app';
|
|
||||||
|
|
||||||
import GameEnvironmentMixin from '@/mixins/GameEnvironmentMixin';
|
import GameEnvironmentMixin from '@/mixins/GameEnvironmentMixin';
|
||||||
|
|
||||||
const store = useAppStore();
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins:[GameEnvironmentMixin],
|
mixins:[GameEnvironmentMixin],
|
||||||
props:{
|
props:{
|
||||||
@@ -57,7 +52,6 @@ export default {
|
|||||||
scenario: null,
|
scenario: null,
|
||||||
renderType: 'ST',
|
renderType: 'ST',
|
||||||
cameraType: 'perspective',
|
cameraType: 'perspective',
|
||||||
store
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<v-form>
|
||||||
|
<v-text-field label="Email" v-model="form.email" type="email" required></v-text-field>
|
||||||
|
<v-text-field label="Password" v-model="form.password" type="password" required></v-text-field>
|
||||||
|
<template v-if="mode==='register'">
|
||||||
|
<v-text-field label="Confirm Password" v-model="form.passConfirm" type="password" required></v-text-field>
|
||||||
|
<v-img :src="`/api/user/captcha?${captchaIter}`" class="my-2" height="100px"></v-img>
|
||||||
|
<v-text-field label="Enter Captcha" v-model="form.captcha" required></v-text-field>
|
||||||
|
</template>
|
||||||
|
<v-btn @click="login" :color="mode==='login' ? 'green' : 'grey'">Sign in</v-btn>
|
||||||
|
<v-btn @click="register" :color="mode==='register' ? 'green' : 'grey'" class="float-right">Sign up</v-btn>
|
||||||
|
</v-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
form:{
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
passConfirm: ''
|
||||||
|
},
|
||||||
|
mode:'login', // or 'register'
|
||||||
|
captchaIter: Math.random()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async login() {
|
||||||
|
// Implement login logic here
|
||||||
|
if (this.mode == 'login') {
|
||||||
|
// Perform login
|
||||||
|
await this.$api.user.signin(this.form);
|
||||||
|
await this.loadUser();
|
||||||
|
} else {
|
||||||
|
this.mode = 'login';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async register() {
|
||||||
|
if (this.mode == 'register') {
|
||||||
|
// Implement registration logic here
|
||||||
|
await this.$api.user.signup(this.form);
|
||||||
|
await this.loadUser();
|
||||||
|
} else {
|
||||||
|
this.mode = 'register';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<v-form>
|
||||||
|
<v-text-field label="Email" disabled v-model="user.email" type="email" required></v-text-field>
|
||||||
|
<v-text-field label="Change Password" v-model="form.password" type="password" required></v-text-field>
|
||||||
|
<v-text-field label="Confirm Password" v-model="form.passConfirm" type="password" required></v-text-field>
|
||||||
|
<v-btn @click="update" color="green">Update Profile</v-btn>
|
||||||
|
<v-btn @click="signout" color="grey" class="float-right">Sign out</v-btn>
|
||||||
|
</v-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
form:{
|
||||||
|
password: '',
|
||||||
|
passConfirm: ''
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async signout() {
|
||||||
|
await this.$api.user.signout();
|
||||||
|
await this.loadUser();
|
||||||
|
},
|
||||||
|
async update() {
|
||||||
|
await this.$api.user.update({...this.form, _id: this.user._id});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppHeader />
|
<ConsoleHeader />
|
||||||
<v-main>
|
<v-main>
|
||||||
<router-view />
|
<router-view />
|
||||||
</v-main>
|
</v-main>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-main>
|
<AppHeader />
|
||||||
<router-view />
|
<v-main>
|
||||||
</v-main>
|
<router-view />
|
||||||
<AppFooter />
|
</v-main>
|
||||||
|
<AppFooter />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
//
|
//
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
const epsilon = 0.1;
|
|
||||||
|
|
||||||
export default {
|
|
||||||
blobToBase64: blob => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
return new Promise(resolve => {
|
|
||||||
reader.onloadend = () => {
|
|
||||||
resolve(reader.result);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
adjustMinMax(r){
|
|
||||||
return {
|
|
||||||
x1: Math.min(r.x1, r.x2), x2: Math.max(r.x1, r.x2),
|
|
||||||
y1: Math.min(r.y1, r.y2), y2: Math.max(r.y1, r.y2)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
intersectPointRect(p, r){
|
|
||||||
//r = this.adjustMinMax(r);
|
|
||||||
return p[0] >= r.x1 && p[0] <= r.x2 && p[1] >= r.y1 && p[1] <= r.y2;
|
|
||||||
},
|
|
||||||
|
|
||||||
intersectPointLine(p, l){
|
|
||||||
//l = this.adjustMinMax(l);
|
|
||||||
let dx = l.x2 - l.x1, dy = l.y2 - l.y1;
|
|
||||||
let c = dy / dx;
|
|
||||||
return this.intersectPointRect(p, l) && c * p[0] - p[1] <= epsilon
|
|
||||||
},
|
|
||||||
|
|
||||||
intersectLineRect(l, r){
|
|
||||||
return this.intersectPointRect([l.x1, l.y1], r) ||
|
|
||||||
this.intersectPointRect([l.x2, l.y2], r);
|
|
||||||
},
|
|
||||||
|
|
||||||
intersectRectRect(r1, r2){
|
|
||||||
return this.intersectPointRect([r1.x1, r1.y1], r2) ||
|
|
||||||
this.intersectPointRect([r1.x1, r1.y2], r2) ||
|
|
||||||
this.intersectPointRect([r1.x2, r1.y1], r2) ||
|
|
||||||
this.intersectPointRect([r1.x2, r1.y2], r2);
|
|
||||||
},
|
|
||||||
|
|
||||||
round(n, p = 2){
|
|
||||||
let pp = Math.pow(10, p);
|
|
||||||
return Math.round(n*pp)/pp;
|
|
||||||
},
|
|
||||||
|
|
||||||
deg2rad(deg){
|
|
||||||
return deg * (Math.PI / 180);
|
|
||||||
},
|
|
||||||
|
|
||||||
rad2deg(rad){
|
|
||||||
return rad * 180 / Math.PI;
|
|
||||||
},
|
|
||||||
|
|
||||||
shuffleArray(arr){
|
|
||||||
return arr.map(value => ({ value, sort: Math.random() }))
|
|
||||||
.sort((a, b) => a.sort - b.sort).map(({ value }) => value)
|
|
||||||
},
|
|
||||||
|
|
||||||
deepMerge(target, source, transformFn) {
|
|
||||||
Object.entries(source).forEach(([key, value]) => {
|
|
||||||
if (transformFn){
|
|
||||||
value = transformFn(key, value)
|
|
||||||
}
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
let dflt = Array.isArray(value) ? [] : {};
|
|
||||||
this.deepMerge(target[key] = target[key] || dflt, value, transformFn);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
target[key] = value;
|
|
||||||
});
|
|
||||||
return target;
|
|
||||||
},
|
|
||||||
|
|
||||||
drawOnCanvas(svg, width, height){
|
|
||||||
return new Promise((resolve, reject)=>{
|
|
||||||
let url = URL.createObjectURL(new Blob([svg],{ type:"image/svg+xml;charset=utf-8" }));
|
|
||||||
let img = new Image();
|
|
||||||
let canvas = document.createElement('canvas');
|
|
||||||
canvas.width = width;
|
|
||||||
canvas.height = height;
|
|
||||||
let ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
img.addEventListener('load', function () {
|
|
||||||
ctx.drawImage(this, 0, 0, canvas.width, canvas.height);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
resolve(canvas);
|
|
||||||
}, { once: true })
|
|
||||||
|
|
||||||
img.src = url;
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async wait(ms){
|
|
||||||
await new Promise((resolve, reject)=>{
|
|
||||||
setTimeout(resolve, ms)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async waitFor(expFn){
|
|
||||||
while (!expFn()){
|
|
||||||
await JsUtils.wait(200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-1
@@ -13,8 +13,11 @@ import App from './App.vue'
|
|||||||
// Composables
|
// Composables
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
const app = createApp(App)
|
// Global Mixins
|
||||||
|
import GlobalMixin from '@/mixins/GlobalMixin';
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
registerPlugins(app)
|
registerPlugins(app)
|
||||||
|
|
||||||
|
app.mixin(GlobalMixin);
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { VideoPlayer } from '@/components/InteractiveObjects/VideoPlayer';
|
|||||||
import { GameEngine } from '@/lib/GameEngine';
|
import { GameEngine } from '@/lib/GameEngine';
|
||||||
import { Hero } from '@/lib/Hero';
|
import { Hero } from '@/lib/Hero';
|
||||||
import { autoScale, getBoundingBox, getBoundingBoxSize } from '@/lib/MeshUtils';
|
import { autoScale, getBoundingBox, getBoundingBoxSize } from '@/lib/MeshUtils';
|
||||||
import Utils from '@/lib/Utils';
|
|
||||||
let gameEngine = null;
|
let gameEngine = null;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useAppStore } from '@/stores/app';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data(){
|
||||||
|
return {
|
||||||
|
store: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created(){
|
||||||
|
this.store = useAppStore();
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
user: {
|
||||||
|
get() {
|
||||||
|
return this.store.user;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.store.user = value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
roles(){
|
||||||
|
let roles = {};
|
||||||
|
this.user && this.user.roles && this.user.roles.forEach(r=>{
|
||||||
|
roles[r] = true;
|
||||||
|
})
|
||||||
|
return roles;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadUser() {
|
||||||
|
let response = await this.$api.user.load();
|
||||||
|
this.user = response.data.user;
|
||||||
|
return this.user;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-1
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Utils from '@/lib/Utils';
|
import Utils from '#/app/Utils';
|
||||||
|
|
||||||
const $ax = axios.create({
|
const $ax = axios.create({
|
||||||
baseURL: '/api/',
|
baseURL: '/api/',
|
||||||
@@ -66,6 +66,21 @@ export default {
|
|||||||
user:{
|
user:{
|
||||||
async tm(action, object, data){
|
async tm(action, object, data){
|
||||||
return await $ax.post('/user/tm', {action, object, data});
|
return await $ax.post('/user/tm', {action, object, data});
|
||||||
|
},
|
||||||
|
async signin(data){
|
||||||
|
return await $ax.post('/user/signin', data);
|
||||||
|
},
|
||||||
|
async signup(data){
|
||||||
|
return await $ax.post('/user/signup', data);
|
||||||
|
},
|
||||||
|
async signout(){
|
||||||
|
return await $ax.get('/user/signout');
|
||||||
|
},
|
||||||
|
async load(){
|
||||||
|
return await $ax.get('/user/info');
|
||||||
|
},
|
||||||
|
async update(data){
|
||||||
|
return await $ax.post('/user/update', data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user