added frontend docker

This commit is contained in:
2023-12-11 00:12:36 +02:00
parent 1bead994bd
commit da01815f27
28 changed files with 20994 additions and 0 deletions

38
src/App.css Normal file
View File

@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

7
src/App.test.tsx Normal file
View File

@@ -0,0 +1,7 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
});

144
src/App.tsx Normal file
View File

@@ -0,0 +1,144 @@
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 { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ApiWrapper, getDesignTokens, GlobalOpenApi, GlobalUserInfo } from "./common";
import { LoginPage } from "./login";
import ServersBoard from "./servers";
import MenuIcon from '@mui/icons-material/Menu';
import StorageIcon from '@mui/icons-material/Storage';
import WebIcon from '@mui/icons-material/Web';
import Person3Icon from '@mui/icons-material/Person3';
import { UsersPage } from "./users";
import { BrowsersPage } from "./browsers";
import { SignupPage } from "./signup";
import Brightness4Icon from '@mui/icons-material/Brightness4';
import Brightness7Icon from '@mui/icons-material/Brightness7';
import Cookies from 'js-cookie'
const ColorModeContext = React.createContext({ toggleColorMode: () => { } });
function Menu(props: { children: ReactNode, setMode: Dispatch<PaletteMode> }) {
const [open, setOpen] = React.useState(false);
const theme = useTheme();
const toggleDrawer = (newOpen: boolean) => () => {
setOpen(newOpen);
};
const colorMode = React.useContext(ColorModeContext);
return (
<Box component={Paper} width='100vw' height='100vh' overflow={'clip'} maxHeight='-webkit-fill-available'>
<AppBar position="static">
<Toolbar>
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}
onClick={() => { setOpen(true) }}
>
<MenuIcon />
</IconButton>
<Box component="div" sx={{ flexGrow: 1 }} />
<IconButton size='large' edge='end' color='inherit' aria-label="menu" sx={{ mr: 2 }} onClick={colorMode.toggleColorMode}>
{theme.palette.mode === 'dark' ? <Brightness7Icon /> : <Brightness4Icon />}
</IconButton>
</Toolbar>
</AppBar>
<SwipeableDrawer
onClose={toggleDrawer(false)}
onOpen={toggleDrawer(true)}
disableSwipeToOpen={false}
ModalProps={
{
keepMounted: true,
}
}
open={open}
sx={{ minWidth: 'max-content' }}
>
<List>
<ListItem key="Servers">
<ListItemButton href='/servers'>
<ListItemIcon>
<StorageIcon />
</ListItemIcon>
<ListItemText primary={'Servers'} />
</ListItemButton>
</ListItem>
<ListItem key="Users" >
<ListItemButton href='/users'>
<ListItemIcon>
<Person3Icon />
</ListItemIcon>
<ListItemText primary={'Users'} />
</ListItemButton>
</ListItem>
<ListItem key="Browsers">
<ListItemButton href='/browsers'>
<ListItemIcon >
<WebIcon />
</ListItemIcon>
<ListItemText primary={'Browsers'} />
</ListItemButton>
</ListItem>
</List>
</SwipeableDrawer>
{props.children}
</Box>
)
}
export default function App() {
let themeName = Cookies.get('theme')
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
if (!themeName) {
themeName = prefersDarkMode ? 'dark' : 'light'
}
const [mode, setMode] = React.useState<PaletteMode>(themeName as PaletteMode);
const colorMode = React.useMemo(
() => ({
toggleColorMode: () => {
setMode((prevMode) => {
const mode = prevMode === 'light' ? 'dark' : 'light'
Cookies.set('theme', mode)
return mode
});
},
}),
[],
);
const theme = React.useMemo(() => createTheme(getDesignTokens(mode)), [mode]);
return (
<ColorModeContext.Provider value={colorMode}>
<Box height={'100vh'} width={'100vw'} overflow='clip' maxHeight='-webkit-fill-available'>
<ThemeProvider theme={theme}>
<BrowserRouter>
<ApiWrapper>
<GlobalOpenApi>
<GlobalUserInfo>
<Menu setMode={setMode}>
<Routes>
<Route path='login' element={<LoginPage />} />
<Route path='signup' element={<SignupPage />} />
<Route path='servers' element={<ServersBoard />} />
<Route path='users' element={<UsersPage />} />
<Route path='browsers' element={<BrowsersPage />} />
<Route index element={<Navigate to='/servers' />} />
</Routes>
</Menu>
</GlobalUserInfo>
</GlobalOpenApi>
</ApiWrapper>
</BrowserRouter>
</ThemeProvider>
</Box>
</ColorModeContext.Provider>
)
}

92
src/browsers.tsx Normal file
View File

@@ -0,0 +1,92 @@
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 validator from '@rjsf/validator-ajv8';
import { Browser, ServerInfo } from "./interfaces";
import { loadServers } from "./servers";
import { JSONSchema7TypeName } from "json-schema";
const browserContext: Context<Browser> = createContext({} as Browser)
const browserActionContext: Context<ActionInfo[]|null> = createContext(null as ActionInfo[]|null)
function FakeAction(props: {action: ActionInfo, browser: Browser}){
return <Button rel="noopener noreferrer" target="_blank" href={`https://${props.browser.url}`}>{props.action.name}</Button>
}
function BrowserActions() {
const actions = useContext(browserActionContext)
const browser = useContext(browserContext)
return <actionIdentifierContext.Provider value={browser.id_}>
<ActionGroup actions={actions? actions:[]} identifierSubstring="browser_id">
<FakeAction action={{name: 'Browse', requestType: 'post', endpoint: '', args:{}}} browser={browser}/>
</ActionGroup>
</actionIdentifierContext.Provider>
}
export function BrowsersPage({ }) {
const [apiAuthenticated, setApiAuthenticated] = useContext(apiAuthenticatedContext)
const [browsers, setBrowsers]: [Browser[], Dispatch<Browser[]>] = useState([]as Browser[])
useEffect(() => {
if (!apiAuthenticated) {
return
}
api.get('/browsers').then((response) => { setBrowsers(response.data) }).catch(
(error) => {
console.log('Failed to get Browsers: ' + error);
if (error.response) {
if (error.response.status === 401) {
setApiAuthenticated(false)
}
else if (error.response.status === 403) {
setApiAuthenticated(false)
}
}
}
)
const interval = setInterval(() => {
api.get('/browsers').then((response) => { setBrowsers(response.data) }).catch(
(error) => {
console.log('Failed to get Browsers: ' + error);
if (error.response) {
if (error.response.status === 401) {
setApiAuthenticated(false)
}
else if (error.response.status === 403) {
setApiAuthenticated(false)
}
}
}
)
}, 5000
);
return () => { clearInterval(interval) }
}, [apiAuthenticated])
let browserComponents = []
for (let browser of browsers) {
browserComponents.push(
<browserContext.Provider value={browser}>
<TableRow>
<TableCell>{browser.owner_id}</TableCell>
<TableCell>{browser.connected_to.user_id}</TableCell>
<TableCell>{browser.connected_to.image.name}</TableCell>
<TableCell>{browser.connected_to.image.version}</TableCell>
<TableCell>
<BrowserActions/>
</TableCell>
</TableRow>
</browserContext.Provider>
)
}
return <DataTable headers={['Browser Owner', 'Server Owner', 'Server Game', 'Game Version', 'Actions']}>
<browserActionContext.Provider value={useActions(api, '/browsers/{browser_id}')}>
{browserComponents}
</browserActionContext.Provider>
</DataTable>
}

485
src/common.tsx Normal file
View File

@@ -0,0 +1,485 @@
import axios, { AxiosInstance, AxiosResponse } from "axios";
import React, { Context, Dispatch, ReactNode, createContext, useContext, useState } from "react";
import { useLocation, Navigate } from "react-router-dom";
import Cookies from 'js-cookie'
import { Box, Button, ButtonGroup, ButtonOwnProps, Modal, PaletteMode, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField } from "@mui/material";
import { Form } from "@rjsf/mui";
import validator from '@rjsf/validator-ajv8';
import { blue, grey } from "@mui/material/colors";
import { OpenAPISchema, User } from "./interfaces";
import { JSONSchema7 } from "json-schema";
import { IChangeEvent } from "@rjsf/core";
import { RJSFSchema, WidgetProps } from "@rjsf/utils";
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import Grow from '@mui/material/Grow';
import Popper from '@mui/material/Popper';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
export const apiAuthenticatedContext: Context<[boolean, Dispatch<boolean>]> = createContext([false, (value: boolean) => {}] as [boolean, Dispatch<boolean>])
export const formModalStyle = {
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '70%',
bgcolor: 'background.paper',
p: 4,
borderRadius: 3,
}
export const getDesignTokens = (mode: PaletteMode) => ({
palette: {
mode,
...(mode === 'light'
? {
// palette values for light mode
primary: blue,
divider: blue[200],
background: {
default: grey[200],
light: grey[100],
},
text: {
primary: grey[900],
secondary: grey[800],
},
}
: {
// palette values for dark mode
primary: grey,
divider: grey[700],
background: {
default: grey[900],
paper: grey[900],
light: grey[700],
},
text: {
primary: '#fff',
secondary: grey[500],
},
}),
},
components: {
MuiTypography: {
defaultProps: {
color: 'text.primary'
}
},
}
});
const API_URL = `${process.env.REACT_APP_API_SCHEME}://${process.env.REACT_APP_API_URL}`
export const api: AxiosInstance = axios.create({
baseURL: API_URL,
});
console.log(process.env)
export function ApiWrapper(p: { children: ReactNode}) {
const {children} = p
const token = Cookies.get('token')
if (token) {
api.defaults.headers.common.Authorization = `Bearer ${token}`;
}
const [apiAuthenticated, setApiAuthenticated] = useState(Boolean(token))
if (!apiAuthenticated) {
Cookies.remove('token', { path: '/', domain: 'games.acooldomain.co' })
}
const path = useLocation()
return (<apiAuthenticatedContext.Provider value={[apiAuthenticated, setApiAuthenticated]}>
{children}
{!apiAuthenticated && (path.pathname !== '/login' && path.pathname !== '/signup') && <Navigate to='/login' />}
</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'
endpoint: string
args: {}
permissions?: string[]
response_action?: 'Ignore' | 'Browse'
}
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({})
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: JSON.parse(JSON.stringify(formSchema).replaceAll('#/components', '#/definitions')),
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
}
export const actionIdentifierContext: Context<string> = createContext('')
function FetcherField(props: WidgetProps){
const jp = require('jsonpath')
const [options2, setOptions]: [Options[]|null, Dispatch<Options[]>] = useState(null as Options[]|null)
const {schema, registry, options, ...newProps} = props
const {SelectWidget} = registry.widgets
if (!schema.fetch_url){
return <TextField onChange={(event)=>(props.onChange(event.target.value))} value={props.value} label={props.label}/>
}
if (options2 === null){
api.get(schema.fetch_url as string).then((event)=>{
let newOptions: Options[] = []
for (let response of event.data){
newOptions.push({
const: jp.query(response, `$.${schema.fetch_key_path}`).join(' '),
label: jp.query(response, `$.${schema.fetch_display_path}`).join(' '),
})
}
setOptions(newOptions)
})
}
return <SelectWidget {...newProps} schema={{oneOf: options2?options2:[]}} registry={registry} options={{enumOptions: (options2?options2:[]).map((value: Options)=>({label: value.label, value: value.const}))}}/>
}
function isUserAllowed(user: User|null, action: ActionInfo): boolean{
if (user === null){
return false
}
const isAdmin = user.permissions.includes('admin')
if (isAdmin){
return true
}
if (!action.permissions){
return true
}
if (action.permissions.every((v)=>(user.permissions.includes(v)))){
return true
}
return false
}
export function ActionItem(p: { action: ActionInfo, identifierSubstring?: string, sx?: ButtonOwnProps, variant?: any, onClick?: Function }) {
const actionIdentifier: string = useContext(actionIdentifierContext)
const identifierSubstring = (typeof p.identifierSubstring !== 'undefined') ? p.identifierSubstring : ''
const user = useContext(UserInfoContext)
const [form, setForm] = useState(false);
const [formData, setFormData]: [RJSFSchema, Dispatch<RJSFSchema>] = useState({})
function handleSubmit() {
let url = p.action.endpoint.replaceAll(`{${identifierSubstring}}`, actionIdentifier)
let promise: Promise<AxiosResponse<any, any>>|null = null
switch (p.action.requestType) {
case 'post': {
promise = api.post(url, formData)
break
}
case 'get': {
if (formData){
console.warn('get can get no arguments, dropping')
}
promise = api.get(url)
break
}
case 'delete': {
if (formData){
console.warn('delete can get no arguments, dropping')
}
promise = api.delete(url)
break
}
}
switch (p.action.response_action){
case 'Browse':{
if (promise !== null){
promise.then((event)=>{window.open(`https://${event.data}`)})
}
}
}
setForm(false)
setFormData({})
}
function onFormChange(args: IChangeEvent<any, RJSFSchema, any>) {
setFormData(args.formData)
}
return (<>
<Button variant={p.variant} disabled={!isUserAllowed(user, p.action)} onClick={() => { if (p.onClick) { p.onClick() } setForm(true) }} sx={p.sx}>{p.action.name}</Button >
<Modal
onClose={() => { setForm(false); setFormData({}); }}
open={form}
>
<Box sx={formModalStyle}>
<Form validator={validator} widgets={{TextWidget: FetcherField}} schema={p.action.args} onChange={onFormChange} formData={formData} onSubmit={handleSubmit} />
</Box>
</Modal>
</>)
}
export function ActionGroup(p: { actions: ActionInfo[], identifierSubstring?: string, children?: ReactNode}) {
const actionItems: any[] = p.actions.map((action, index, array) => (<ActionItem action={action} identifierSubstring={p.identifierSubstring} /> ))
const [open, setOpen] = React.useState(false);
const anchorRef = React.useRef<HTMLDivElement>(null);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const user = useContext(UserInfoContext)
for (let child of React.Children.toArray(p.children)){
actionItems.push(child)
}
const handleMenuItemClick = (
event: React.MouseEvent<HTMLLIElement, MouseEvent>,
index: number,
) => {
setSelectedIndex(index);
setOpen(false);
};
const handleToggle = () => {
setOpen((prevOpen) => !prevOpen);
};
const handleClose = (event: Event) => {
if (
anchorRef.current &&
anchorRef.current.contains(event.target as HTMLElement)
) {
return;
}
setOpen(false);
}
return (
<React.Fragment>
<ButtonGroup variant="outlined" ref={anchorRef} aria-label="split button">
{actionItems[selectedIndex]}
<Button
size="small"
aria-controls={open ? 'split-button-menu' : undefined}
aria-expanded={open ? 'true' : undefined}
aria-label="select merge strategy"
aria-haspopup="menu"
onClick={handleToggle}
>
<ArrowDropDownIcon />
</Button>
</ButtonGroup>
<Popper
sx={{
zIndex: 1,
}}
open={open}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin:
placement === 'bottom' ? 'center top' : 'center bottom',
}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList id="split-button-menu" autoFocusItem>
{actionItems.map((option, index) => {
if (!option.props){
console.log(actionItems)
}
return <MenuItem
key={option.props.action.name}
selected={index === selectedIndex}
onClick={(event) => handleMenuItemClick(event, index)}
disabled={!isUserAllowed(user, option.props.action)}
>
{option.props.action.name}
</MenuItem>
}
)
}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</React.Fragment>
);
}
export function DataTable(props: { headers: string[], children: ReactNode, actionInfo?: ActionInfo, actionHook?: Function }) {
const { children, headers, actionInfo, actionHook } = props
return <Box padding={4} overflow='clip'>
<TableContainer component={Paper} sx={{maxHeight: '80svh'}}>
<Table stickyHeader>
<TableHead>
<TableRow sx={{ backgroundColor: 'background.light', fontWeight: 'bold' }}>
{headers.map((value, index, array) => (<TableCell sx={{ backgroundColor: 'background.light', fontWeight: 'bold' }}>{value}</TableCell>))}
</TableRow>
</TableHead>
<TableBody>
{children}
</TableBody>
</Table>
</TableContainer>
{(actionInfo && <Box marginTop={2} overflow='clip'>
<ActionItem variant="contained" action={actionInfo} onClick={actionHook} />
</Box>)}
</Box>
}
export const UserInfoContext: Context<User|null> = createContext(null as User|null)
export function GlobalUserInfo(props: {children: any}){
const [user, setUser]: [User|null, Dispatch<User|null>] = useState(null as User|null)
const [apiAuthenticated, _] = useContext(apiAuthenticatedContext)
if (user === null && apiAuthenticated){
api.get('/users/@me').then((event)=>{setUser(event.data)}).catch(()=>{setUser(null)})
}
return <UserInfoContext.Provider value={user}>
{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>
}

13
src/index.css Normal file
View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

19
src/index.tsx Normal file
View File

@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

56
src/interfaces.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { JSONSchema6, JSONSchema7 } from "json-schema"
export interface Port {
number: number
protocol: 'tcp' | 'udp'
}
export interface ImageInfo {
id_: string
name: string
version: string
ports: Port[]
}
export interface ServerInfo {
id_: string
name: string
on: boolean
user_id: string
image: ImageInfo
ports: Port[] | null
domain: string
nickname?: string
}
export interface User {
username: string
email: string
permissions: string[]
}
export interface Browser {
id_: string
domain: string
url: string
owner_id: string
connected_to: ServerInfo
}
export interface OpenApiMethodSchema {
summary: string
requestBody: {content: Record<string, {schema: JSONSchema7}>}
api_response: 'Ignore' | 'Browse'
permissions: string[]
}
export interface OpenAPISchema {
paths: Record<string, {get?: OpenApiMethodSchema, post?: OpenApiMethodSchema, delete?: OpenApiMethodSchema}>
components: {schema: Record<string, JSONSchema7>}
}

103
src/login.tsx Normal file
View File

@@ -0,0 +1,103 @@
import { CssBaseline, Box, Typography, TextField, FormControlLabel, Checkbox, Container, Button } from "@mui/material";
import React, { FormEventHandler, useContext } from "react";
import { Navigate } from "react-router-dom";
import { api, apiAuthenticatedContext } from "./common";
import Cookies from 'js-cookie'
export const fetchToken = async (username: string, password: string, remember: boolean) => {
try {
const response = await api.post('/token', {
username: username,
password: password,
remember: remember,
}, {
});
return response.data.access_token;
} catch (error) {
console.error('Error fetching token:', error);
throw error;
}
};
export function LoginPage(props: {}) {
const [apiAuthenticated, setApiAuthenticated] = useContext(apiAuthenticatedContext)
if (apiAuthenticated) {
return <Navigate to='/' />
}
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const usernameFormData: FormDataEntryValue | null = data.get('username');
const passwordFormData: FormDataEntryValue | null = data.get('password');
if (usernameFormData === null || passwordFormData === null){
return
}
const username: string = usernameFormData.toString();
const password: string = passwordFormData.toString();
fetchToken(username, password, Boolean(data.get('remember'))).then(
(token) => {
api.defaults.headers.common.Authorization = `Bearer ${token}`;
Cookies.set('token', token)
setApiAuthenticated(true)
},
(error) => {
return Promise.reject(error);
}
)
}
return (
<Container component="main" maxWidth="xs">
<CssBaseline />
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography component="h1" variant="h5">
Sign in
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
id="username"
label="User Name"
name="username"
autoFocus
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="current-password"
/>
<FormControlLabel
control={<Checkbox value={true} name='remember' color="primary" />}
label="Remember me"
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Sign In
</Button>
</Box>
</Box>
</Container>
)
}

1
src/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

1
src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

15
src/reportWebVitals.ts Normal file
View File

@@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

154
src/servers.tsx Normal file
View File

@@ -0,0 +1,154 @@
import { AxiosInstance } from "axios"
import { ActionGroup, ActionInfo, DataTable, UserInfoContext, actionIdentifierContext, api, apiAuthenticatedContext, useAction, useActions } 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"
export async function loadServers(api: AxiosInstance): Promise<{ status: number, data: ServerInfo[] }> {
let response = await api.get('/servers')
return {
status: response.status,
data: response.data
}
}
const serverActionsContext: Context<ActionInfo[]> = createContext([] as ActionInfo[])
function ServerItem(props: { server_info: ServerInfo }) {
const actions = useContext(serverActionsContext)
const [serverPermissions, setServerPermissions] = useState(null as null|string[])
const user = useContext(UserInfoContext)
let permissions: string[] = []
if (props.server_info.user_id === user?.username){
permissions.push('admin')
}else{
if (serverPermissions === null){
api.get(`/servers/${props.server_info.id_}/permissions`).then((event)=>{setServerPermissions(event.data)})
}else{
permissions.push(...serverPermissions)
}
if (user){
permissions.push(...user.permissions)
}
}
const name = `${props.server_info.user_id}'s ${props.server_info.image.name} ${props.server_info.image.version} Server`
if (props.server_info.ports === null) {
props.server_info.ports = []
}
return (
<UserInfoContext.Provider value={user? {username: user.username, email: user.email, permissions: permissions}: null}>
<actionIdentifierContext.Provider value={props.server_info.id_}>
<TableRow>
<TableCell>{props.server_info.nickname}</TableCell>
<TableCell>{props.server_info.user_id}</TableCell>
<TableCell>{props.server_info.image.name}</TableCell>
<TableCell>{props.server_info.image.version}</TableCell>
<TableCell>{props.server_info.domain}</TableCell>
<TableCell>{props.server_info.ports.map((port, index, array) => { return <Chip label={`${port.number}/${port.protocol}`} /> })}</TableCell>
<TableCell>
<ActionGroup actions={actions} identifierSubstring="server_id" />
</TableCell>
</TableRow>
</actionIdentifierContext.Provider>
</UserInfoContext.Provider>
)
}
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) {
return
}
let servers_promised = loadServers(api)
servers_promised.then((response) => {
setServers(response.data)
})
.catch(
(error) => {
console.log('Failed to get servers: ' + error);
if (error.response) {
if (error.response.status === 401) {
setApiAuthenticated(false)
}
else if (error.response.status === 403) {
setApiAuthenticated(false)
}
}
}
)
}
useEffect(() => {
handleServers()
const interval = setInterval(() => {
handleServers()
}, 5000
);
return () => { clearInterval(interval) }
}, [apiAuthenticated])
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)
}
}
}
)
}}>
<serverActionsContext.Provider value={useActions(api, '/servers/{server_id}')}>
{
servers.sort((s1: ServerInfo, s2: ServerInfo) => { return s1.id_ < s2.id_ ? 0 : 1 }).map(
(value: ServerInfo, index: number, array) => {
return <ServerItem server_info={value} />
}
)
}
</serverActionsContext.Provider>
</DataTable>
);
}

5
src/setupTests.ts Normal file
View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

109
src/signup.tsx Normal file
View File

@@ -0,0 +1,109 @@
import { CssBaseline, Box, Typography, TextField, FormControlLabel, Checkbox, Container, Button } from "@mui/material";
import React, { FormEvent, FormEventHandler, useContext } from "react";
import { Navigate, useSearchParams } from "react-router-dom";
import { api, apiAuthenticatedContext } from "./common";
import Cookies from 'js-cookie'
import { fetchToken } from "./login";
const signUp = async (username: string, password: string, token: string) => {
try {
const response = await api.post(`/signup?token=${token}`, {
username: username,
password: password,
}, {
});
return response.data.access_token;
} catch (error) {
console.error('Error fetching token:', error);
throw error;
}
};
export function SignupPage(props: {}) {
const [apiAuthenticated, setApiAuthenticated] = useContext(apiAuthenticatedContext)
const [searchParam, setSearchParam] = useSearchParams();
const token = searchParam.get('token');
if (token === null){
return <Navigate to='/' />
}
if (apiAuthenticated) {
return <Navigate to='/' />
}
const handleSubmit: FormEventHandler<HTMLFormElement> = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const usernameFormData: FormDataEntryValue | null = data.get('username');
const passwordFormData: FormDataEntryValue | null = data.get('password');
if (usernameFormData === null || passwordFormData === null){
return
}
const username: string = usernameFormData.toString();
const password: string = passwordFormData.toString();
signUp(username, password, token).then(
() => {
fetchToken(username, password, true).then(
(token) => {
api.defaults.headers.common.Authorization = `Bearer ${token}`;
;
Cookies.set('token', token)
setApiAuthenticated(true)
},
(error) => {
return Promise.reject(error);
}
)
}
)
}
return (
<Container component="main" maxWidth="xs">
<CssBaseline />
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography component="h1" variant="h5">
Sign up
</Typography>
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
<TextField
margin="normal"
required
fullWidth
id="username"
label="User Name"
name="username"
autoFocus
/>
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="new-password"
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Sign Up
</Button>
</Box>
</Box>
</Container>
)
}

80
src/users.tsx Normal file
View File

@@ -0,0 +1,80 @@
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 { User } from './interfaces'
const UserActionsContext: Context<ActionInfo[]> = createContext([] as ActionInfo[])
function UserItem(p: { user: User }) {
const user = p.user;
const userActions = useContext(UserActionsContext)
return (
<TableRow>
<TableCell>{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.permissions.map((value, index, array) => { return <Chip label={value} /> })}</TableCell>
<TableCell><ActionGroup actions={userActions} identifierSubstring="username" /></TableCell>
</TableRow>
)
}
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'})
useEffect(() => {
if (!apiAuthenticated) {
return
}
api.get('/users').then((response) => { setUsers(response.data) }).catch(
(error) => {
console.log('Failed to get servers: ' + error);
if (error.response) {
if (error.response.status === 401) {
setApiAuthenticated(false)
}
else if (error.response.status === 403) {
setApiAuthenticated(false)
}
}
}
)
const interval = setInterval(() => {
api.get('/users').then((response) => { setUsers(response.data) }).catch(
(error) => {
console.log('Failed to get users: ' + error);
if (error.response) {
if (error.response.status === 401) {
setApiAuthenticated(false)
}
else if (error.response.status === 403) {
setApiAuthenticated(false)
}
}
}
)
}, 5000
);
return () => { clearInterval(interval) }
}, [apiAuthenticated])
let userComponents = []
for (let user of users) {
userComponents.push(<actionIdentifierContext.Provider value={user.username}><UserItem user={user} /></actionIdentifierContext.Provider>)
}
return <DataTable headers={['Username', 'Email', 'Permissions', 'Actions']} actionInfo={action}>
<UserActionsContext.Provider value={useActions(api, '/users/{username}')}>
{userComponents}
</UserActionsContext.Provider>
</DataTable>
}