updated ui
This commit is contained in:
parent
c7667ec5b5
commit
2ebcf0a8c4
20976
package-lock.json
generated
20976
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@ -1,8 +1,8 @@
|
||||
{
|
||||
name: "a-cool-games-manager",
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
dependencies: {
|
||||
"name": "a-cool-games-manager",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@rjsf/core": "^5.14.2",
|
||||
"@rjsf/mui": "^5.14.2",
|
||||
"@rjsf/utils": "^5.14.2",
|
||||
@ -16,39 +16,39 @@
|
||||
"@types/react": "^18.2.38",
|
||||
"@types/react-dom": "^18.2.16",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
axios: "^1.6.2",
|
||||
dotenv: "^16.3.1",
|
||||
"axios": "^1.6.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
jsonpath: "^1.1.1",
|
||||
react: "^18.2.0",
|
||||
"jsonpath": "^1.1.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-jsonschema-form": "^1.8.1",
|
||||
"react-router-dom": "^6.19.0",
|
||||
"react-routes": "^0.2.6",
|
||||
"react-scripts": "5.0.1",
|
||||
serve: "^14.2.1",
|
||||
typescript: "^4.9.5",
|
||||
"serve": "^14.2.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
scripts: {
|
||||
start: "react-scripts start",
|
||||
build: "/usr/bin/env react-scripts build",
|
||||
test: "react-scripts test",
|
||||
eject: "react-scripts eject"
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "/usr/bin/env react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
eslintConfig: {
|
||||
extends: [
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
browserslist: {
|
||||
production: [
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
development: [
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
|
@ -1,25 +1,25 @@
|
||||
{
|
||||
short_name: "React App",
|
||||
name: "Create React App Sample",
|
||||
icons: [
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
src: "favicon.ico",
|
||||
sizes: "64x64 32x32 24x24 16x16",
|
||||
type: "image/x-icon"
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
src: "logo192.png",
|
||||
type: "image/png",
|
||||
sizes: "192x192"
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
type: "image/png",
|
||||
sizes: "512x512"
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
start_url: ".",
|
||||
display: "standalone",
|
||||
theme_color: "#000000",
|
||||
background_color: "#ffffff"
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Box, Paper, ThemeProvider, List, ListItem, ListItemButton, ListItemText, SwipeableDrawer, ListItemIcon, IconButton, AppBar, Toolbar, PaletteMode, createTheme, useMediaQuery, useTheme } from "@mui/material";
|
||||
import React, { Dispatch, ReactNode, useState } from "react";
|
||||
import React, { Dispatch, ReactNode } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ApiWrapper, getDesignTokens, GlobalOpenApi, GlobalUserInfo } from "./common";
|
||||
import { ApiWrapper, getDesignTokens, GlobalUserInfo } from "./common";
|
||||
import { LoginPage } from "./login";
|
||||
import ServersBoard from "./servers";
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
@ -121,7 +121,6 @@ export default function App() {
|
||||
<ThemeProvider theme={theme}>
|
||||
<BrowserRouter>
|
||||
<ApiWrapper>
|
||||
<GlobalOpenApi>
|
||||
<GlobalUserInfo>
|
||||
<Menu setMode={setMode}>
|
||||
<Routes>
|
||||
@ -134,7 +133,6 @@ export default function App() {
|
||||
</Routes>
|
||||
</Menu>
|
||||
</GlobalUserInfo>
|
||||
</GlobalOpenApi>
|
||||
</ApiWrapper>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
|
@ -596,3 +596,27 @@ export const SERVER_ACTIONS: ActionInfo[] = [
|
||||
response_action: "Ignore"
|
||||
}
|
||||
]
|
||||
|
||||
export const CREATE_SERVER_ACTION: ActionInfo = {
|
||||
name: "Create Server",
|
||||
args: {
|
||||
"$ref": "#/definitions/CreateServer",
|
||||
definitions: definitions,
|
||||
},
|
||||
requestType: "post",
|
||||
endpoint: "/servers",
|
||||
permissions: Permission.Create,
|
||||
response_action: "Ignore"
|
||||
}
|
||||
|
||||
export const INVITE_USER_ACTION: ActionInfo = {
|
||||
name: "Create Server",
|
||||
args: {
|
||||
"$ref": "#/definitions/InviteUserRequests",
|
||||
definitions: definitions,
|
||||
},
|
||||
requestType: "post",
|
||||
endpoint: "/users",
|
||||
permissions: Permission.Admin,
|
||||
response_action: "Ignore"
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { TableRow, TableCell, TableContainer, TableHead, Table, Button, Popover, Paper, TableBody, Chip, Link, ButtonGroup, Modal, Box } from "@mui/material"
|
||||
import React, { useContext, Dispatch, useState, useEffect, createContext, Context } from "react"
|
||||
import Form from "@rjsf/mui"
|
||||
import { apiAuthenticatedContext, useApiDoc, api, ActionItem, formModalStyle, DataTable, useActions, ActionInfo, ActionGroup, actionIdentifierContext } from "./common"
|
||||
import { apiAuthenticatedContext, api, DataTable, ActionInfo, ActionGroup, actionIdentifierContext } from "./common"
|
||||
import validator from '@rjsf/validator-ajv8';
|
||||
import { Browser, ServerInfo } from "./interfaces";
|
||||
import { loadServers } from "./servers";
|
||||
@ -85,7 +85,7 @@ export function BrowsersPage({ }) {
|
||||
)
|
||||
}
|
||||
return <DataTable headers={['Browser Owner', 'Server Owner', 'Server Game', 'Game Version', 'Actions']}>
|
||||
<browserActionContext.Provider value={useActions(api, '/browsers/{browser_id}')}>
|
||||
<browserActionContext.Provider value={[]}>
|
||||
{browserComponents}
|
||||
</browserActionContext.Provider>
|
||||
</DataTable>
|
||||
|
128
src/common.tsx
128
src/common.tsx
@ -79,8 +79,6 @@ export const api: AxiosInstance = axios.create({
|
||||
baseURL: API_URL,
|
||||
});
|
||||
|
||||
console.log(process.env)
|
||||
|
||||
export function ApiWrapper(p: { children: ReactNode}) {
|
||||
const {children} = p
|
||||
const token = localStorage.getItem('token')
|
||||
@ -99,34 +97,6 @@ export function ApiWrapper(p: { children: ReactNode}) {
|
||||
</apiAuthenticatedContext.Provider>)
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function useApiDoc(api: AxiosInstance, path: string, method: 'get' | 'post' | 'delete') {
|
||||
const [apiRecord, setApiRecord] = useState(null as OpenAPISchema|null)
|
||||
api.get('/openapi.json').then(
|
||||
(value) => {
|
||||
setApiRecord(value.data)
|
||||
}
|
||||
)
|
||||
|
||||
if (apiRecord) {
|
||||
const schema = apiRecord.paths[path][method]
|
||||
if (!schema){
|
||||
return
|
||||
}
|
||||
|
||||
let formSchema: JSONSchema7 = { title: schema.summary, type: 'object', required: [], properties: {}, definitions: apiRecord.components, default: {} }
|
||||
if (schema.requestBody) {
|
||||
formSchema = schema.requestBody.content['application/json'].schema
|
||||
formSchema.definitions = apiRecord.components
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(formSchema).replaceAll('#/components', '#/definitions'))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
export interface ActionInfo {
|
||||
name: string
|
||||
requestType: 'post' | 'get' | 'delete'
|
||||
@ -137,87 +107,6 @@ export interface ActionInfo {
|
||||
}
|
||||
|
||||
|
||||
export function useActions(api: AxiosInstance, path_prefix: string): ActionInfo[] {
|
||||
const openapi: OpenAPISchema|null = useContext(OpenApiContext)
|
||||
let [actions, setActions]: [Record<string, ActionInfo[]>, Dispatch<Record<string, ActionInfo[]>>] = useState({})
|
||||
console.log(actions)
|
||||
if (actions[path_prefix] && actions[path_prefix].length > 0) {
|
||||
return actions[path_prefix]
|
||||
}
|
||||
if (openapi === null) {
|
||||
return []
|
||||
}
|
||||
|
||||
let responseActions: ActionInfo[] = []
|
||||
let paths = openapi.paths
|
||||
for (let [path, request] of Object.entries(paths)) {
|
||||
if (path.startsWith(path_prefix)) {
|
||||
for (let [method, schema] of Object.entries(request)) {
|
||||
let formSchema: JSONSchema7 = { title: schema.summary, type: 'object', required: [], properties: {}, definitions: openapi.components, default: {} }
|
||||
if (schema.requestBody) {
|
||||
formSchema = schema.requestBody.content['application/json'].schema
|
||||
formSchema.definitions = openapi.components
|
||||
}
|
||||
responseActions.push(
|
||||
{
|
||||
name: schema.summary,
|
||||
args: formSchema,
|
||||
requestType: (method as 'get' | 'post' | 'delete'),
|
||||
endpoint: path,
|
||||
permissions: schema.permissions,
|
||||
response_action: schema.api_response,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actions[path_prefix] = responseActions
|
||||
setActions(actions)
|
||||
|
||||
if (actions[path_prefix]) {
|
||||
return actions[path_prefix]
|
||||
}
|
||||
else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function useAction(p: {path: string, method: 'post' | 'get' | 'delete'}): ActionInfo|undefined{
|
||||
const apiDoc: OpenAPISchema|null = useContext(OpenApiContext)
|
||||
const [action, setAction]: [ActionInfo|null, Dispatch<ActionInfo|null>] = useState(null as ActionInfo|null)
|
||||
if (action != null){
|
||||
return action
|
||||
}
|
||||
|
||||
if (!apiDoc){
|
||||
return
|
||||
}
|
||||
const schema = apiDoc.paths[p.path][p.method]
|
||||
if (!schema){
|
||||
return
|
||||
}
|
||||
|
||||
let formSchema: JSONSchema7 = { title: schema.summary, type: 'object', required: [], properties: {}, definitions: apiDoc.components, default: {} }
|
||||
if (schema.requestBody) {
|
||||
formSchema = schema.requestBody.content['application/json'].schema
|
||||
formSchema.definitions = apiDoc.components
|
||||
}
|
||||
const calculatedAction = {
|
||||
name: schema.summary,
|
||||
args: JSON.parse(JSON.stringify(formSchema).replaceAll('#/components', '#/definitions')),
|
||||
requestType: p.method,
|
||||
endpoint: p.path,
|
||||
permissions: schema.permissions,
|
||||
response_action: schema.api_response,
|
||||
}
|
||||
setAction(calculatedAction)
|
||||
|
||||
return calculatedAction
|
||||
|
||||
}
|
||||
|
||||
|
||||
interface Options{
|
||||
label: string
|
||||
const: string
|
||||
@ -253,6 +142,7 @@ function FetcherField(props: WidgetProps){
|
||||
|
||||
|
||||
function isUserAllowed(user: User|null, action: ActionInfo): boolean{
|
||||
console.log({User: user, Action: action})
|
||||
if (user === null){
|
||||
return false
|
||||
}
|
||||
@ -468,19 +358,3 @@ export function GlobalUserInfo(props: {children: any}){
|
||||
{props.children}
|
||||
</UserInfoContext.Provider>
|
||||
}
|
||||
|
||||
|
||||
export const OpenApiContext: Context<OpenAPISchema|null> = createContext(null as OpenAPISchema|null)
|
||||
export function GlobalOpenApi(props: {children: any}){
|
||||
const [openApi, setOpenApi]: [OpenAPISchema|null, Dispatch<OpenAPISchema|null>] = useState(null as OpenAPISchema|null)
|
||||
if (openApi === null){
|
||||
api.get('/openapi.json').then((event)=>{
|
||||
setOpenApi(event.data)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return <OpenApiContext.Provider value={openApi}>
|
||||
{props.children}
|
||||
</OpenApiContext.Provider>
|
||||
}
|
||||
|
@ -6,14 +6,14 @@ import Cookies from 'js-cookie'
|
||||
|
||||
export const fetchToken = async (username: string, password: string, remember: boolean) => {
|
||||
try {
|
||||
const response = await api.post('/token', {
|
||||
const response = await api.post('/auth/signin', {
|
||||
username: username,
|
||||
password: password,
|
||||
remember: remember,
|
||||
}, {
|
||||
withCredentials: true
|
||||
});
|
||||
return response.data.access_token;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching token:', error);
|
||||
throw error;
|
||||
@ -41,7 +41,7 @@ export function LoginPage(props: {}) {
|
||||
fetchToken(username, password, Boolean(data.get('remember'))).then(
|
||||
(token) => {
|
||||
api.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
localStorage.setItem('token', token)
|
||||
Cookies.set('auth', token)
|
||||
setApiAuthenticated(true)
|
||||
},
|
||||
(error) => {
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { AxiosInstance } from "axios"
|
||||
import { ActionGroup, ActionInfo, DataTable, UserInfoContext, actionIdentifierContext, api, apiAuthenticatedContext, useAction, useActions } from "./common"
|
||||
import { ActionGroup, ActionInfo, DataTable, UserInfoContext, actionIdentifierContext, api, apiAuthenticatedContext} from "./common"
|
||||
import React, { Context, Dispatch, createContext, useContext, useEffect, useState } from "react"
|
||||
import { TableRow, TableCell, Chip } from "@mui/material"
|
||||
import { ImageInfo, ServerInfo } from "./interfaces"
|
||||
import { JSONSchema7 } from "json-schema"
|
||||
import { Permission, SERVER_ACTIONS } from "./actions"
|
||||
import { CREATE_SERVER_ACTION, Permission, SERVER_ACTIONS } from "./actions"
|
||||
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ function ServerItem(props: { server_info: ServerInfo }) {
|
||||
const [serverPermissions, setServerPermissions] = useState(null as null|number)
|
||||
const user = useContext(UserInfoContext)
|
||||
let permissions = 0
|
||||
|
||||
console.log(user)
|
||||
if (props.server_info.OwnerId === user?.Username){
|
||||
permissions |= Permission.Admin
|
||||
}else{
|
||||
@ -68,23 +68,6 @@ function ServerItem(props: { server_info: ServerInfo }) {
|
||||
export default function ServersBoard() {
|
||||
const [servers, setServers]: [ServerInfo[], Dispatch<ServerInfo[]>] = useState([] as ServerInfo[]);
|
||||
const [apiAuthenticated, setApiAuthenticated] = useContext(apiAuthenticatedContext)
|
||||
const [images, setImages] = useState([] as ImageInfo[])
|
||||
|
||||
let schema: JSONSchema7 = {
|
||||
properties: {
|
||||
image_id: {
|
||||
type: "string",
|
||||
oneOf: images.map((value, index, array) => { return { const: value.Id, title: `${value.Name} ${value.Version}` } }),
|
||||
title: "Image Id"
|
||||
}
|
||||
},
|
||||
type: "object",
|
||||
required: [
|
||||
"image_id"
|
||||
],
|
||||
title: "CreateServer"
|
||||
}
|
||||
|
||||
|
||||
function handleServers() {
|
||||
if (!apiAuthenticated) {
|
||||
@ -123,23 +106,7 @@ export default function ServersBoard() {
|
||||
|
||||
|
||||
return (
|
||||
<DataTable headers={['Nickname', 'Owner', 'Server', 'Version', 'Domain', 'Ports', 'Actions']} actionInfo={useAction({path: '/servers', method: 'post'})} actionHook={() => {
|
||||
api.get('/images').then((value) => {
|
||||
setImages(value.data)
|
||||
}).catch(
|
||||
(error) => {
|
||||
console.log('Failed to get images: ' + error);
|
||||
if (error.response) {
|
||||
if (error.response.status === 401) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
else if (error.response.status === 403) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}}>
|
||||
<DataTable headers={['Nickname', 'Owner', 'Server', 'Version', 'Domain', 'Ports', 'Actions']} actionInfo={CREATE_SERVER_ACTION}>
|
||||
<serverActionsContext.Provider value={SERVER_ACTIONS}>
|
||||
{
|
||||
servers.sort((s1: ServerInfo, s2: ServerInfo) => { return s1.Id < s2.Id ? 0 : 1 }).map(
|
||||
|
@ -1,12 +1,35 @@
|
||||
import { TableRow, TableCell, TableContainer, TableHead, Table, Button, Paper, TableBody, Chip, Modal, Box } from "@mui/material"
|
||||
import React, { useContext, Dispatch, useState, useEffect, Context, createContext } from "react"
|
||||
import Form from "@rjsf/mui"
|
||||
import { apiAuthenticatedContext, useApiDoc, api, ActionInfo, useActions, ActionGroup, actionIdentifierContext, ActionItem, DataTable, useAction } from "./common"
|
||||
import validator from '@rjsf/validator-ajv8';
|
||||
import { TableRow, TableCell, Chip } from "@mui/material"
|
||||
import { useContext, Dispatch, useState, useEffect, Context, createContext } from "react"
|
||||
import { apiAuthenticatedContext, api, ActionInfo, ActionGroup, actionIdentifierContext, DataTable } from "./common"
|
||||
import { User } from './interfaces'
|
||||
import { INVITE_USER_ACTION, Permission } from "./actions";
|
||||
|
||||
const UserActionsContext: Context<ActionInfo[]> = createContext([] as ActionInfo[])
|
||||
|
||||
function getPermissionStrings(permissions: number){
|
||||
let strings = []
|
||||
if (permissions & Permission.Admin){
|
||||
strings.push("Admin")
|
||||
}
|
||||
if (permissions & Permission.Start){
|
||||
strings.push("Start")
|
||||
}
|
||||
if (permissions & Permission.Stop){
|
||||
strings.push("Stop")
|
||||
}
|
||||
if (permissions & Permission.Browse){
|
||||
strings.push("Browse")
|
||||
}
|
||||
if (permissions & Permission.Create){
|
||||
strings.push("Create")
|
||||
}
|
||||
if (permissions & Permission.Delete){
|
||||
strings.push("Delete")
|
||||
}
|
||||
if (permissions & Permission.RunCommand){
|
||||
strings.push("RunCommand")
|
||||
}
|
||||
return strings
|
||||
}
|
||||
|
||||
function UserItem(p: { user: User }) {
|
||||
const user = p.user;
|
||||
@ -16,7 +39,7 @@ function UserItem(p: { user: User }) {
|
||||
<TableRow>
|
||||
<TableCell>{user.Username}</TableCell>
|
||||
<TableCell>{user.Email}</TableCell>
|
||||
<TableCell>{user.Permissions.map((value, index, array) => { return <Chip label={value} /> })}</TableCell>
|
||||
<TableCell>{getPermissionStrings(user.Permissions).map((value, index, array) => { return <Chip label={value} /> })}</TableCell>
|
||||
<TableCell><ActionGroup actions={userActions} identifierSubstring="username" /></TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
@ -26,7 +49,7 @@ export function UsersPage({ }) {
|
||||
const [apiAuthenticated, setApiAuthenticated] = useContext(apiAuthenticatedContext)
|
||||
const [users, setUsers]: [User[], Dispatch<User[]>] = useState([] as User[])
|
||||
|
||||
const action: ActionInfo|undefined = useAction({path: '/users', method: 'post'})
|
||||
const action: ActionInfo|undefined = INVITE_USER_ACTION
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiAuthenticated) {
|
||||
@ -73,7 +96,7 @@ export function UsersPage({ }) {
|
||||
}
|
||||
|
||||
return <DataTable headers={['Username', 'Email', 'Permissions', 'Actions']} actionInfo={action}>
|
||||
<UserActionsContext.Provider value={useActions(api, '/users/{username}')}>
|
||||
<UserActionsContext.Provider value={[]}>
|
||||
{userComponents}
|
||||
</UserActionsContext.Provider>
|
||||
</DataTable>
|
||||
|
@ -1,26 +1,26 @@
|
||||
{
|
||||
compilerOptions: {
|
||||
target: "es5",
|
||||
lib: [
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
allowJs: true,
|
||||
skipLibCheck: true,
|
||||
esModuleInterop: true,
|
||||
allowSyntheticDefaultImports: true,
|
||||
strict: true,
|
||||
forceConsistentCasingInFileNames: true,
|
||||
noFallthroughCasesInSwitch: true,
|
||||
module: "esnext",
|
||||
moduleResolution: "node",
|
||||
resolveJsonModule: true,
|
||||
isolatedModules: true,
|
||||
noEmit: true,
|
||||
jsx: "react-jsx"
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
include: [
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user