mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-17 06:37:23 +01:00
Client fixes (#8844)
* client: fix router when not authenticated, small fixes for tasks * load the user only when necessary * fix tests
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import generateStore from 'client/store';
|
import generateStore from 'client/store';
|
||||||
|
|
||||||
describe('tasks actions', () => {
|
describe('user actions', () => {
|
||||||
let store;
|
let store;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
<!-- Entry point component for the entire app -->
|
|
||||||
|
|
||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
#app
|
#app
|
||||||
app-menu(v-if="userLoggedIn")
|
router-view(v-if="!isUserLoggedIn || isStaticPage")
|
||||||
.container-fluid(v-if="userLoggedIn")
|
template(v-else)
|
||||||
app-header
|
#loading-screen.h-100.w-100.d-flex.justify-content-center.align-items-center(v-if="!isUserLoaded")
|
||||||
router-view
|
p Loading...
|
||||||
|
template(v-else)
|
||||||
router-view(v-if="!userLoggedIn")
|
app-menu
|
||||||
|
.container-fluid
|
||||||
|
app-header
|
||||||
|
router-view
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import AppMenu from './components/appMenu';
|
import AppMenu from './components/appMenu';
|
||||||
import AppHeader from './components/appHeader';
|
import AppHeader from './components/appHeader';
|
||||||
import axios from 'axios';
|
import { mapState } from 'client/libs/store';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'app',
|
name: 'app',
|
||||||
@@ -23,44 +24,32 @@ export default {
|
|||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
userLoggedIn: false,
|
isUserLoaded: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async beforeCreate () {
|
computed: {
|
||||||
|
...mapState(['isUserLoggedIn']),
|
||||||
|
isStaticPage () {
|
||||||
|
return this.$route.meta.requiresLogin === false ? true : false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created () {
|
||||||
// Setup listener for title
|
// Setup listener for title
|
||||||
this.$store.watch(state => state.title, (title) => {
|
this.$store.watch(state => state.title, (title) => {
|
||||||
document.title = title;
|
document.title = title;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mount the app when user and tasks are loaded
|
if (this.isUserLoggedIn && !this.isStaticPage) {
|
||||||
const userDataWatcher = this.$store.watch(state => [state.user.data, state.tasks.data], ([user, tasks]) => {
|
// Load the user and the user tasks
|
||||||
if (user && user._id && Array.isArray(tasks)) {
|
Promise.all([
|
||||||
userDataWatcher(); // remove the watcher
|
this.$store.dispatch('user:fetch'),
|
||||||
// this.$mount('#app');
|
this.$store.dispatch('tasks:fetchUserTasks'),
|
||||||
}
|
]).then(() => {
|
||||||
});
|
this.isUserLoaded = true;
|
||||||
|
}).catch((err) => {
|
||||||
// @TODO: Move this to store?
|
console.error('Impossible to fetch user. Clean up localStorage and refresh.', err); // eslint-disable-line no-console
|
||||||
let authSettings = localStorage.getItem('habit-mobile-settings');
|
});
|
||||||
if (!authSettings) return;
|
}
|
||||||
|
|
||||||
authSettings = JSON.parse(authSettings);
|
|
||||||
axios.defaults.headers.common['x-api-user'] = authSettings.auth.apiId;
|
|
||||||
axios.defaults.headers.common['x-api-key'] = authSettings.auth.apiToken;
|
|
||||||
|
|
||||||
// Load the user and the user tasks
|
|
||||||
await Promise.all([
|
|
||||||
this.$store.dispatch('user:fetch'),
|
|
||||||
this.$store.dispatch('tasks:fetchUserTasks'),
|
|
||||||
]).catch((err) => {
|
|
||||||
console.error('Impossible to fetch user. Copy into localStorage a valid habit-mobile-settings object.', err); // eslint-disable-line no-console
|
|
||||||
});
|
|
||||||
|
|
||||||
this.userLoggedIn = true;
|
|
||||||
},
|
|
||||||
mounted () { // Remove the loading screen when the app is mounted
|
|
||||||
let loadingScreen = document.getElementById('loading-screen');
|
|
||||||
if (loadingScreen) document.body.removeChild(loadingScreen);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,51 +6,79 @@
|
|||||||
|
|
||||||
&-worst {
|
&-worst {
|
||||||
background: $maroon-100;
|
background: $maroon-100;
|
||||||
&-control {
|
&-control-habit {
|
||||||
background: darken($maroon-100, 12%);
|
background: darken($maroon-100, 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-control-daily-todo {
|
||||||
|
background: $maroon-500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-worse {
|
&-worse {
|
||||||
background: $red-100;
|
background: $red-100;
|
||||||
&-control {
|
&-control-habit {
|
||||||
background: darken($red-100, 12%);
|
background: darken($red-100, 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-control-daily-todo {
|
||||||
|
background: $red-500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-bad {
|
&-bad {
|
||||||
background: $orange-100;
|
background: $orange-100;
|
||||||
&-control {
|
&-control-habit {
|
||||||
background: darken($orange-100, 12%);
|
background: darken($orange-100, 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-control-daily-todo {
|
||||||
|
background: $orange-500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-neutral {
|
&-neutral {
|
||||||
background: $yellow-50;
|
background: $yellow-50;
|
||||||
&-control {
|
&-control-habit {
|
||||||
background: darken($yellow-50, 12%);
|
background: darken($yellow-50, 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-control-daily-todo {
|
||||||
|
background: $yellow-500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-good {
|
&-good {
|
||||||
background: $green-10;
|
background: $green-10;
|
||||||
&-control {
|
&-control-habit {
|
||||||
background: darken($green-10, 12%);
|
background: darken($green-10, 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-control-daily-todo {
|
||||||
|
background: $green-500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-better {
|
&-better {
|
||||||
background: $blue-50;
|
background: $blue-50;
|
||||||
&-control {
|
&-control-habit {
|
||||||
background: darken($blue-50, 12%);
|
background: darken($blue-50, 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-control-daily-todo {
|
||||||
|
background: $blue-500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-best {
|
&-best {
|
||||||
background: $teal-50;
|
background: $teal-50;
|
||||||
&-control {
|
&-control-habit {
|
||||||
background: darken($teal-50, 12%);
|
background: darken($teal-50, 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-control-daily-todo {
|
||||||
|
background: $teal-500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-reward {
|
&-reward {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
#app-header.row
|
#app-header.row
|
||||||
member-details(:member="user", @click="$router.push({name: 'avatar'})")
|
member-details(:member="user", @click="$router.push({name: 'avatar'})")
|
||||||
.view-party
|
.view-party(v-if="user.party && user.party._id")
|
||||||
// TODO button should open the party members modal
|
// TODO button should open the party members modal
|
||||||
router-link.btn.btn-primary(:active-class="''", :to="{name: 'party'}") {{ $t('viewParty') }}
|
router-link.btn.btn-primary(:active-class="''", :to="{name: 'party'}") {{ $t('viewParty') }}
|
||||||
.party-members.d-flex(v-if="partyMembers && partyMembers.length > 1")
|
.party-members.d-flex(v-if="partyMembers && partyMembers.length > 1")
|
||||||
@@ -62,27 +62,13 @@
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-party, .party-members {
|
h3 {
|
||||||
flex-grow: 1;
|
color: $white;
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.party-members {
|
.btn {
|
||||||
overflow-x: auto;
|
margin-top: 16px;
|
||||||
}
|
|
||||||
|
|
||||||
.no-party {
|
|
||||||
.small-text {
|
|
||||||
color: $header-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
color: $white;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -119,12 +105,9 @@ export default {
|
|||||||
this.expandedMember = memberId;
|
this.expandedMember = memberId;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
launchPartyModal () {
|
|
||||||
this.$root.$emit('show::modal', 'create-party-modal');
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
this.getPartyMembers();
|
if (this.user.party && this.user.party._id) this.getPartyMembers();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ nav.navbar.navbar-inverse.fixed-top.navbar-toggleable-sm
|
|||||||
router-link.dropdown-item(:to="{name: 'stable'}") {{ $t('stable') }}
|
router-link.dropdown-item(:to="{name: 'stable'}") {{ $t('stable') }}
|
||||||
router-link.nav-item(tag="li", :to="{name: 'shops'}", exact)
|
router-link.nav-item(tag="li", :to="{name: 'shops'}", exact)
|
||||||
a.nav-link(v-once) {{ $t('shops') }}
|
a.nav-link(v-once) {{ $t('shops') }}
|
||||||
router-link.nav-item.dropdown(:to="{name: 'party'}")
|
router-link.nav-item(tag="li", :to="{name: 'party'}")
|
||||||
a.nav-link(v-once) {{ $t('party') }}
|
a.nav-link(v-once) {{ $t('party') }}
|
||||||
router-link.nav-item.dropdown(tag="li", :to="{name: 'tavern'}", :class="{'active': $route.path.startsWith('/guilds')}")
|
router-link.nav-item.dropdown(tag="li", :to="{name: 'tavern'}", :class="{'active': $route.path.startsWith('/guilds')}")
|
||||||
a.nav-link(v-once) {{ $t('guilds') }}
|
a.nav-link(v-once) {{ $t('guilds') }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
nav
|
nav
|
||||||
a(href='/login') Login
|
router-link(:to="{name: 'login'}") Login
|
||||||
a(href='/register') Register
|
router-link(:to="{name: 'register'}") Register
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
.task.d-flex
|
.task.d-flex
|
||||||
// Habits left side control
|
// Habits left side control
|
||||||
.left-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'habit'", :class="controlClass.up")
|
.left-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'habit'", :class="controlClass.up")
|
||||||
.task-control.habit-control(:class="controlClass.up + '-control'")
|
.task-control.habit-control(:class="controlClass.up + '-control-habit'")
|
||||||
.svg-icon.positive(v-html="icons.positive")
|
.svg-icon.positive(v-html="icons.positive")
|
||||||
// Dailies and todos left side control
|
// Dailies and todos left side control
|
||||||
.left-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'daily' || task.type === 'todo'", :class="controlClass")
|
.left-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'daily' || task.type === 'todo'", :class="controlClass")
|
||||||
.task-control.daily-todo-control(:class="controlClass + '-control'")
|
.task-control.daily-todo-control(:class="controlClass + '-control-daily-todo'")
|
||||||
.svg-icon.check(v-html="icons.check", v-if="task.completed")
|
.svg-icon.check(v-html="icons.check", v-if="task.completed")
|
||||||
// Task title, description and icons
|
// Task title, description and icons
|
||||||
.task-content(:class="contentClass")
|
.task-content(:class="contentClass")
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
// Habits right side control
|
// Habits right side control
|
||||||
.right-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'habit'", :class="controlClass.down")
|
.right-control.d-flex.align-items-center.justify-content-center(v-if="task.type === 'habit'", :class="controlClass.down")
|
||||||
.task-control.habit-control(:class="controlClass.down + '-control'")
|
.task-control.habit-control(:class="controlClass.down + '-control-habit'")
|
||||||
.svg-icon.negative(v-html="icons.negative")
|
.svg-icon.negative(v-html="icons.negative")
|
||||||
// Rewards right side control
|
// Rewards right side control
|
||||||
.right-control.d-flex.align-items-center.justify-content-center.reward-control(v-if="task.type === 'reward'", :class="controlClass")
|
.right-control.d-flex.align-items-center.justify-content-center.reward-control(v-if="task.type === 'reward'", :class="controlClass")
|
||||||
|
|||||||
@@ -4,14 +4,9 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Habitica</title>
|
<title>Habitica</title>
|
||||||
<!-- TODO load google fonts separately as @import is slow, find alternative -->
|
|
||||||
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700,700i|Roboto:400,400i,700,700i" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css?family=Roboto+Condensed:400,400i,700,700i|Roboto:400,400i,700,700i" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- #loading-screen needs to be rendered before vue, will be deleted once app is loaded -->
|
|
||||||
<div id="loading-screen" class="h-100 w-100 d-flex justify-content-center align-items-center">
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<!-- built files will be auto injected -->
|
<!-- built files will be auto injected -->
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
require('babel-polyfill');
|
require('babel-polyfill');
|
||||||
|
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import axios from 'axios';
|
|
||||||
import AppComponent from './app';
|
import AppComponent from './app';
|
||||||
import router from './router';
|
import router from './router';
|
||||||
import generateStore from './store';
|
import getStore from './store';
|
||||||
import StoreModule from './libs/store';
|
import StoreModule from './libs/store';
|
||||||
import './filters/registerGlobals';
|
import './filters/registerGlobals';
|
||||||
import i18n from './libs/i18n';
|
import i18n from './libs/i18n';
|
||||||
@@ -26,18 +25,9 @@ Vue.config.productionTip = IS_PRODUCTION;
|
|||||||
Vue.use(i18n);
|
Vue.use(i18n);
|
||||||
Vue.use(StoreModule);
|
Vue.use(StoreModule);
|
||||||
|
|
||||||
// TODO just until we have proper authentication
|
|
||||||
let authSettings = localStorage.getItem('habit-mobile-settings');
|
|
||||||
|
|
||||||
if (authSettings) {
|
|
||||||
authSettings = JSON.parse(authSettings);
|
|
||||||
axios.defaults.headers.common['x-api-user'] = authSettings.auth.apiId;
|
|
||||||
axios.defaults.headers.common['x-api-key'] = authSettings.auth.apiToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new Vue({
|
export default new Vue({
|
||||||
el: '#app',
|
el: '#app',
|
||||||
router,
|
router,
|
||||||
store: generateStore(),
|
store: getStore(),
|
||||||
render: h => h(AppComponent),
|
render: h => h(AppComponent),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import VueRouter from 'vue-router';
|
import VueRouter from 'vue-router';
|
||||||
|
import getStore from 'client/store';
|
||||||
|
|
||||||
import EmptyView from './components/emptyView';
|
import EmptyView from './components/emptyView';
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ const GuildPage = () => import(/* webpackChunkName: "guilds" */ './components/gu
|
|||||||
|
|
||||||
Vue.use(VueRouter);
|
Vue.use(VueRouter);
|
||||||
|
|
||||||
export default new VueRouter({
|
const router = new VueRouter({
|
||||||
mode: 'history',
|
mode: 'history',
|
||||||
base: process.env.NODE_ENV === 'production' ? '/new-app' : __dirname, // eslint-disable-line no-process-env
|
base: process.env.NODE_ENV === 'production' ? '/new-app' : __dirname, // eslint-disable-line no-process-env
|
||||||
linkActiveClass: 'active',
|
linkActiveClass: 'active',
|
||||||
@@ -47,10 +48,11 @@ export default new VueRouter({
|
|||||||
scrollBehavior () {
|
scrollBehavior () {
|
||||||
return { x: 0, y: 0 };
|
return { x: 0, y: 0 };
|
||||||
},
|
},
|
||||||
|
// requiresLogin is true by default, isStatic false
|
||||||
routes: [
|
routes: [
|
||||||
{ name: 'home', path: '/home', component: Home },
|
{ name: 'home', path: '/home', component: Home, meta: {requiresLogin: false} },
|
||||||
{ name: 'register', path: '/register', component: RegisterLogin },
|
{ name: 'register', path: '/register', component: RegisterLogin, meta: {requiresLogin: false} },
|
||||||
{ name: 'login', path: '/login', component: RegisterLogin },
|
{ name: 'login', path: '/login', component: RegisterLogin, meta: {requiresLogin: false} },
|
||||||
{ name: 'tasks', path: '/', component: UserTasks },
|
{ name: 'tasks', path: '/', component: UserTasks },
|
||||||
{
|
{
|
||||||
path: '/inventory',
|
path: '/inventory',
|
||||||
@@ -115,3 +117,22 @@ export default new VueRouter({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const store = getStore();
|
||||||
|
|
||||||
|
router.beforeEach(function routerGuard (to, from, next) {
|
||||||
|
const isUserLoggedIn = store.state.isUserLoggedIn;
|
||||||
|
const routeRequiresLogin = to.meta.requiresLogin !== false;
|
||||||
|
|
||||||
|
if (!isUserLoggedIn && routeRequiresLogin) {
|
||||||
|
// Redirect to the login page unless the user is trying to reach the
|
||||||
|
// root of the website, in which case show the home page.
|
||||||
|
// TODO when redirecting to login if user login then redirect back to initial page
|
||||||
|
// so if you tried to go to /party you'll be redirected to /party after login/signup
|
||||||
|
return next({name: to.path === '/' ? 'home' : 'login'});
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|||||||
@@ -3,18 +3,40 @@ import deepFreeze from 'client/libs/deepFreeze';
|
|||||||
import content from 'common/script/content/index';
|
import content from 'common/script/content/index';
|
||||||
import * as constants from 'common/script/constants';
|
import * as constants from 'common/script/constants';
|
||||||
import { asyncResourceFactory } from 'client/libs/asyncResource';
|
import { asyncResourceFactory } from 'client/libs/asyncResource';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
import actions from './actions';
|
import actions from './actions';
|
||||||
import getters from './getters';
|
import getters from './getters';
|
||||||
|
|
||||||
|
const IS_TEST = process.env.NODE_ENV === 'test'; // eslint-disable-line no-process-env
|
||||||
|
|
||||||
|
// Load user auth parameters and determine if it's logged in
|
||||||
|
// before trying to load data
|
||||||
|
let isUserLoggedIn = false;
|
||||||
|
|
||||||
|
let AUTH_SETTINGS = localStorage.getItem('habit-mobile-settings');
|
||||||
|
|
||||||
|
if (AUTH_SETTINGS) {
|
||||||
|
AUTH_SETTINGS = JSON.parse(AUTH_SETTINGS);
|
||||||
|
axios.defaults.headers.common['x-api-user'] = AUTH_SETTINGS.auth.apiId;
|
||||||
|
axios.defaults.headers.common['x-api-key'] = AUTH_SETTINGS.auth.apiToken;
|
||||||
|
isUserLoggedIn = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Export a function that generates the store and not the store directly
|
// Export a function that generates the store and not the store directly
|
||||||
// so that we can regenerate it multiple times for testing
|
// so that we can regenerate it multiple times for testing, when not testing
|
||||||
|
// always export the same route
|
||||||
|
|
||||||
|
let existingStore;
|
||||||
export default function () {
|
export default function () {
|
||||||
return new Store({
|
if (!IS_TEST && existingStore) return existingStore;
|
||||||
|
|
||||||
|
existingStore = new Store({
|
||||||
actions,
|
actions,
|
||||||
getters,
|
getters,
|
||||||
state: {
|
state: {
|
||||||
title: 'Habitica',
|
title: 'Habitica',
|
||||||
|
isUserLoggedIn,
|
||||||
user: asyncResourceFactory(),
|
user: asyncResourceFactory(),
|
||||||
tasks: asyncResourceFactory(), // user tasks
|
tasks: asyncResourceFactory(), // user tasks
|
||||||
party: {
|
party: {
|
||||||
@@ -30,4 +52,6 @@ export default function () {
|
|||||||
constants: deepFreeze(constants),
|
constants: deepFreeze(constants),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return existingStore;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user