mirror of
https://github.com/mattermost/focalboard.git
synced 2025-02-13 19:42:12 +02:00
Preliminary auth implementation
This commit is contained in:
parent
99cefc5356
commit
f491241c1a
17
Makefile
17
Makefile
@ -24,6 +24,20 @@ server-linux:
|
|||||||
server-win:
|
server-win:
|
||||||
cd server; env GOOS=windows GOARCH=amd64 go build -o ../bin/octoserver.exe ./main
|
cd server; env GOOS=windows GOARCH=amd64 go build -o ../bin/octoserver.exe ./main
|
||||||
|
|
||||||
|
server-single-user:
|
||||||
|
cd server; go build -o ../bin/octoserver ./main --single-user
|
||||||
|
|
||||||
|
server-mac-single-user:
|
||||||
|
mkdir -p bin/mac
|
||||||
|
cd server; env GOOS=darwin GOARCH=amd64 go build -o ../bin/mac/octoserver ./main --single-user
|
||||||
|
|
||||||
|
server-linux-single-user:
|
||||||
|
mkdir -p bin/linux
|
||||||
|
cd server; env GOOS=linux GOARCH=amd64 go build -o ../bin/linux/octoserver ./main --single-user
|
||||||
|
|
||||||
|
server-win-single-user:
|
||||||
|
cd server; env GOOS=windows GOARCH=amd64 go build -o ../bin/octoserver.exe ./main --single-user
|
||||||
|
|
||||||
generate:
|
generate:
|
||||||
cd server; go get -modfile=go.tools.mod github.com/golang/mock/mockgen
|
cd server; go get -modfile=go.tools.mod github.com/golang/mock/mockgen
|
||||||
cd server; go get -modfile=go.tools.mod github.com/jteeuwen/go-bindata
|
cd server; go get -modfile=go.tools.mod github.com/jteeuwen/go-bindata
|
||||||
@ -45,6 +59,9 @@ server-doc:
|
|||||||
watch-server:
|
watch-server:
|
||||||
cd server; modd
|
cd server; modd
|
||||||
|
|
||||||
|
watch-server-single-user:
|
||||||
|
cd server; env OCTOSERVER_ARGS=--single-user modd
|
||||||
|
|
||||||
webapp:
|
webapp:
|
||||||
cd webapp; npm run pack
|
cd webapp; npm run pack
|
||||||
|
|
||||||
|
@ -60,12 +60,12 @@ func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if loginData.Type == "normal" {
|
if loginData.Type == "normal" {
|
||||||
jwtToken, err := a.app().Login(loginData.Username, loginData.Email, loginData.Password, loginData.MfaToken)
|
token, err := a.app().Login(loginData.Username, loginData.Email, loginData.Password, loginData.MfaToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
errorResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
json, err := json.Marshal(jwtToken)
|
json, err := json.Marshal(map[string]string{"token": token})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf(`ERROR json.Marshal: %v`, r)
|
log.Printf(`ERROR json.Marshal: %v`, r)
|
||||||
errorResponse(w, http.StatusInternalServerError, nil)
|
errorResponse(w, http.StatusInternalServerError, nil)
|
||||||
@ -111,6 +111,7 @@ func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *API) sessionRequired(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
|
func (a *API) sessionRequired(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf(`Single User: %v`, a.singleUser)
|
||||||
if a.singleUser {
|
if a.singleUser {
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
session := &model.Session{
|
session := &model.Session{
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/mattermost/mattermost-octo-tasks/server/model"
|
"github.com/mattermost/mattermost-octo-tasks/server/model"
|
||||||
"github.com/mattermost/mattermost-octo-tasks/server/services/auth"
|
"github.com/mattermost/mattermost-octo-tasks/server/services/auth"
|
||||||
@ -38,6 +40,7 @@ func (a *App) Login(username string, email string, password string, mfaToken str
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !auth.ComparePassword(user.Password, password) {
|
if !auth.ComparePassword(user.Password, password) {
|
||||||
|
log.Printf("Not valid passowrd. %s (%s)\n", password, user.Password)
|
||||||
return "", errors.New("invalid username or password")
|
return "", errors.New("invalid username or password")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,11 +56,13 @@ func main() {
|
|||||||
// Command line args
|
// Command line args
|
||||||
pMonitorPid := flag.Int("monitorpid", -1, "a process ID")
|
pMonitorPid := flag.Int("monitorpid", -1, "a process ID")
|
||||||
pPort := flag.Int("port", config.Port, "the port number")
|
pPort := flag.Int("port", config.Port, "the port number")
|
||||||
|
pSingleUser := flag.Bool("single-user", false, "single user mode")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
singleUser := false
|
singleUser := false
|
||||||
if pSingleUser := flag.Bool("single-user", false, "single user mode"); pSingleUser != nil {
|
if pSingleUser != nil {
|
||||||
singleUser = *pSingleUser
|
singleUser = *pSingleUser
|
||||||
}
|
}
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if pMonitorPid != nil && *pMonitorPid > 0 {
|
if pMonitorPid != nil && *pMonitorPid > 0 {
|
||||||
monitorPid(*pMonitorPid)
|
monitorPid(*pMonitorPid)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
**/*.go !**/*_test.go {
|
**/*.go !**/*_test.go {
|
||||||
prep: go build -o ../bin/octoserver ./main
|
prep: go build -o ../bin/octoserver ./main
|
||||||
daemon +sigterm: cd .. && ./bin/octoserver
|
daemon +sigterm: cd .. && ./bin/octoserver $OCTOSERVER_ARGS
|
||||||
}
|
}
|
||||||
|
@ -39,7 +39,6 @@ func HashPassword(password string) string {
|
|||||||
|
|
||||||
// ComparePassword compares the hash
|
// ComparePassword compares the hash
|
||||||
func ComparePassword(hash string, password string) bool {
|
func ComparePassword(hash string, password string) bool {
|
||||||
|
|
||||||
if len(password) == 0 || len(hash) == 0 {
|
if len(password) == 0 || len(hash) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ const (
|
|||||||
HEADER_TOKEN = "token"
|
HEADER_TOKEN = "token"
|
||||||
HEADER_AUTH = "Authorization"
|
HEADER_AUTH = "Authorization"
|
||||||
HEADER_BEARER = "BEARER"
|
HEADER_BEARER = "BEARER"
|
||||||
SESSION_COOKIE_TOKEN = "MMAUTHTOKEN"
|
SESSION_COOKIE_TOKEN = "OCTOTASKSAUTHTOKEN"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TokenLocation int
|
type TokenLocation int
|
||||||
|
@ -7,13 +7,17 @@ import {
|
|||||||
BrowserRouter as Router,
|
BrowserRouter as Router,
|
||||||
Switch,
|
Switch,
|
||||||
Route,
|
Route,
|
||||||
|
Redirect,
|
||||||
} from 'react-router-dom'
|
} from 'react-router-dom'
|
||||||
|
|
||||||
|
import client from './octoClient'
|
||||||
|
|
||||||
import {getCurrentLanguage, getMessages, storeLanguage} from './i18n'
|
import {getCurrentLanguage, getMessages, storeLanguage} from './i18n'
|
||||||
|
|
||||||
import {FlashMessages} from './components/flashMessages'
|
import {FlashMessages} from './components/flashMessages'
|
||||||
|
|
||||||
import LoginPage from './pages/loginPage'
|
import LoginPage from './pages/loginPage'
|
||||||
|
import RegisterPage from './pages/registerPage'
|
||||||
import BoardPage from './pages/boardPage'
|
import BoardPage from './pages/boardPage'
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
@ -35,10 +39,15 @@ export default function App(): JSX.Element {
|
|||||||
<Route path='/login'>
|
<Route path='/login'>
|
||||||
<LoginPage/>
|
<LoginPage/>
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path='/register'>
|
||||||
|
<RegisterPage/>
|
||||||
|
</Route>
|
||||||
<Route path='/'>
|
<Route path='/'>
|
||||||
|
{!client.token && <Redirect to='/login'/>}
|
||||||
<BoardPage setLanguage={setAndStoreLanguage}/>
|
<BoardPage setLanguage={setAndStoreLanguage}/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path='/board'>
|
<Route path='/board'>
|
||||||
|
{!client.token && <Redirect to='/login'/>}
|
||||||
<BoardPage setLanguage={setAndStoreLanguage}/>
|
<BoardPage setLanguage={setAndStoreLanguage}/>
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
@ -8,15 +8,59 @@ import {Utils} from './utils'
|
|||||||
//
|
//
|
||||||
class OctoClient {
|
class OctoClient {
|
||||||
serverUrl: string
|
serverUrl: string
|
||||||
|
token?: string
|
||||||
|
|
||||||
constructor(serverUrl?: string) {
|
constructor(serverUrl?: string, token?: string) {
|
||||||
this.serverUrl = serverUrl || window.location.origin
|
this.serverUrl = serverUrl || window.location.origin
|
||||||
|
this.token = token
|
||||||
Utils.log(`OctoClient serverUrl: ${this.serverUrl}`)
|
Utils.log(`OctoClient serverUrl: ${this.serverUrl}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async login(username: string, password: string): Promise<boolean> {
|
||||||
|
const path = '/api/v1/login'
|
||||||
|
const body = JSON.stringify({username, password, type: 'normal'})
|
||||||
|
const response = await fetch(this.serverUrl + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.headers(),
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
if (response.status === 200) {
|
||||||
|
const responseJson = (await response.json() || {}) as {token?: string}
|
||||||
|
this.token = responseJson.token
|
||||||
|
if (responseJson.token !== '') {
|
||||||
|
localStorage.setItem('sessionId', this.token || '')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(email: string, username: string, password: string): Promise<boolean> {
|
||||||
|
const path = '/api/v1/register'
|
||||||
|
const body = JSON.stringify({email, username, password})
|
||||||
|
const response = await fetch(this.serverUrl + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.headers(),
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
if (response.status === 200) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
headers() {
|
||||||
|
return {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: this.token ? 'Bearer ' + this.token : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getSubtree(rootId?: string, levels = 2): Promise<IBlock[]> {
|
async getSubtree(rootId?: string, levels = 2): Promise<IBlock[]> {
|
||||||
const path = `/api/v1/blocks/${rootId}/subtree?l=${levels}`
|
const path = `/api/v1/blocks/${rootId}/subtree?l=${levels}`
|
||||||
const response = await fetch(this.serverUrl + path)
|
const response = await fetch(this.serverUrl + path, {headers: this.headers()})
|
||||||
const blocks = (await response.json() || []) as IMutableBlock[]
|
const blocks = (await response.json() || []) as IMutableBlock[]
|
||||||
this.fixBlocks(blocks)
|
this.fixBlocks(blocks)
|
||||||
return blocks
|
return blocks
|
||||||
@ -24,7 +68,7 @@ class OctoClient {
|
|||||||
|
|
||||||
async exportFullArchive(): Promise<IBlock[]> {
|
async exportFullArchive(): Promise<IBlock[]> {
|
||||||
const path = '/api/v1/blocks/export'
|
const path = '/api/v1/blocks/export'
|
||||||
const response = await fetch(this.serverUrl + path)
|
const response = await fetch(this.serverUrl + path, {headers: this.headers()})
|
||||||
const blocks = (await response.json() || []) as IMutableBlock[]
|
const blocks = (await response.json() || []) as IMutableBlock[]
|
||||||
this.fixBlocks(blocks)
|
this.fixBlocks(blocks)
|
||||||
return blocks
|
return blocks
|
||||||
@ -38,10 +82,7 @@ class OctoClient {
|
|||||||
const body = JSON.stringify(blocks)
|
const body = JSON.stringify(blocks)
|
||||||
return fetch(this.serverUrl + '/api/v1/blocks/import', {
|
return fetch(this.serverUrl + '/api/v1/blocks/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: this.headers(),
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -62,7 +103,7 @@ class OctoClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getBlocksWithPath(path: string): Promise<IBlock[]> {
|
private async getBlocksWithPath(path: string): Promise<IBlock[]> {
|
||||||
const response = await fetch(this.serverUrl + path)
|
const response = await fetch(this.serverUrl + path, {headers: this.headers()})
|
||||||
const blocks = (await response.json() || []) as IMutableBlock[]
|
const blocks = (await response.json() || []) as IMutableBlock[]
|
||||||
this.fixBlocks(blocks)
|
this.fixBlocks(blocks)
|
||||||
return blocks
|
return blocks
|
||||||
@ -98,10 +139,7 @@ class OctoClient {
|
|||||||
Utils.log(`deleteBlock: ${blockId}`)
|
Utils.log(`deleteBlock: ${blockId}`)
|
||||||
return fetch(this.serverUrl + `/api/v1/blocks/${encodeURIComponent(blockId)}`, {
|
return fetch(this.serverUrl + `/api/v1/blocks/${encodeURIComponent(blockId)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: this.headers(),
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,10 +155,7 @@ class OctoClient {
|
|||||||
const body = JSON.stringify(blocks)
|
const body = JSON.stringify(blocks)
|
||||||
return fetch(this.serverUrl + '/api/v1/blocks', {
|
return fetch(this.serverUrl + '/api/v1/blocks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: this.headers(),
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -138,6 +173,7 @@ class OctoClient {
|
|||||||
// TIPTIP: Leave out Content-Type here, it will be automatically set by the browser
|
// TIPTIP: Leave out Content-Type here, it will be automatically set by the browser
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
|
Authorization: this.token ? 'Bearer ' + this.token : '',
|
||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
@ -161,6 +197,6 @@ class OctoClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new OctoClient()
|
const client = new OctoClient(undefined, localStorage.getItem('sessionId') || '')
|
||||||
|
|
||||||
export default client
|
export default client
|
||||||
|
@ -2,26 +2,34 @@
|
|||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import {Utils} from '../utils'
|
import {
|
||||||
|
withRouter,
|
||||||
|
RouteComponentProps,
|
||||||
|
Link,
|
||||||
|
} from 'react-router-dom'
|
||||||
|
|
||||||
import Button from '../widgets/buttons/button'
|
import Button from '../widgets/buttons/button'
|
||||||
|
import client from '../octoClient'
|
||||||
import './loginPage.scss'
|
import './loginPage.scss'
|
||||||
|
|
||||||
type Props = {
|
type Props = RouteComponentProps
|
||||||
}
|
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class LoginPage extends React.PureComponent<Props, State> {
|
class LoginPage extends React.PureComponent<Props, State> {
|
||||||
state = {
|
state = {
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleLogin = (): void => {
|
private handleLogin = async (): Promise<void> => {
|
||||||
Utils.log('Logging in')
|
const logged = await client.login(this.state.username, this.state.password)
|
||||||
|
if (logged) {
|
||||||
|
this.props.history.push('/')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render(): React.ReactNode {
|
render(): React.ReactNode {
|
||||||
@ -45,7 +53,10 @@ export default class LoginPage extends React.PureComponent<Props, State> {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={this.handleLogin}>{'Login'}</Button>
|
<Button onClick={this.handleLogin}>{'Login'}</Button>
|
||||||
|
<Link to='/register'>{'or create an account if you don\'t have one'}</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withRouter(LoginPage)
|
||||||
|
27
webapp/src/pages/registerPage.scss
Normal file
27
webapp/src/pages/registerPage.scss
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
.RegisterPage {
|
||||||
|
border: 1px solid #cccccc;
|
||||||
|
border-radius: 15px;
|
||||||
|
width: 450px;
|
||||||
|
height: 400px;
|
||||||
|
margin: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
.email, .username, .password {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
label {
|
||||||
|
display: inline-block;
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
display: inline-block;
|
||||||
|
width: 250px;
|
||||||
|
border: 1px solid #cccccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.Button {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
74
webapp/src/pages/registerPage.tsx
Normal file
74
webapp/src/pages/registerPage.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
withRouter,
|
||||||
|
RouteComponentProps,
|
||||||
|
Link,
|
||||||
|
} from 'react-router-dom'
|
||||||
|
|
||||||
|
import Button from '../widgets/buttons/button'
|
||||||
|
import client from '../octoClient'
|
||||||
|
import './registerPage.scss'
|
||||||
|
|
||||||
|
type Props = RouteComponentProps
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class RegisterPage extends React.PureComponent<Props, State> {
|
||||||
|
state = {
|
||||||
|
email: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRegister = async (): Promise<void> => {
|
||||||
|
const registered = await client.register(this.state.email, this.state.username, this.state.password)
|
||||||
|
if (registered) {
|
||||||
|
const logged = await client.login(this.state.username, this.state.password)
|
||||||
|
if (logged) {
|
||||||
|
this.props.history.push('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<div className='RegisterPage'>
|
||||||
|
<div className='email'>
|
||||||
|
<label htmlFor='login-email'>{'Email'}</label>
|
||||||
|
<input
|
||||||
|
id='login-email'
|
||||||
|
value={this.state.email}
|
||||||
|
onChange={(e) => this.setState({email: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='username'>
|
||||||
|
<label htmlFor='login-username'>{'Username'}</label>
|
||||||
|
<input
|
||||||
|
id='login-username'
|
||||||
|
value={this.state.username}
|
||||||
|
onChange={(e) => this.setState({username: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='password'>
|
||||||
|
<label htmlFor='login-username'>{'Password'}</label>
|
||||||
|
<input
|
||||||
|
id='login-password'
|
||||||
|
type='password'
|
||||||
|
value={this.state.password}
|
||||||
|
onChange={(e) => this.setState({password: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={this.handleRegister}>{'Register'}</Button>
|
||||||
|
<Link to='/login'>{'or login if you already have an account'}</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default withRouter(RegisterPage)
|
Loading…
x
Reference in New Issue
Block a user