frontend/src/common.tsx
2024-06-02 17:08:42 +03:00

430 lines
15 KiB
TypeScript

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 { User } from "./interfaces";
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';
import { Permission } from "./actions";
import TerminalComponent from "./terminal";
import ReactDOM from "react-dom/client";
export const apiAuthenticatedContext: Context<[boolean, Dispatch<boolean>]> = createContext([false, (value: boolean) => {}] as [boolean, Dispatch<boolean>])
export const formModalStyle = {
position: 'absolute' as 'absolute',
maxHeight: "90%",
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '70%',
bgcolor: 'background.paper',
overflowY: 'auto',
p: 4,
borderRadius: 3,
}
export const terminalModalStyle = {
position: 'absolute' as 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '70%',
bgcolor: 'background.paper',
p: 1,
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}`
axios.defaults.withCredentials = true
export const api: AxiosInstance = axios.create({
baseURL: API_URL,
withCredentials: true,
});
export function ApiWrapper(p: { children: ReactNode}) {
const {children} = p
const token = Cookies.get('auth')
const [apiAuthenticated, setApiAuthenticated] = useState(Boolean(token))
if (!apiAuthenticated) {
Cookies.remove('auth')
}
const path = useLocation()
return (<apiAuthenticatedContext.Provider value={[apiAuthenticated, setApiAuthenticated]}>
{children}
{!apiAuthenticated && (path.pathname !== '/login' && path.pathname !== '/signup') && <Navigate to='/login' />}
</apiAuthenticatedContext.Provider>)
}
export interface ActionInfo {
name: string
requestType: 'post' | 'get' | 'delete' | 'patch'
endpoint: string
args: {}
permissions?: number
response_action?: 'Ignore' | 'Browse' | 'Terminal'
ServerState?: 'on' | 'off'
}
interface Options{
label: string
const: string
}
export const actionIdentifierContext: Context<string> = createContext('')
function convertNumber(permissions: number): number[]{
var arr: number[] = []
Object.entries(Permission).forEach(
([key, value]) => {
if (permissions&value){
arr.push(value)
}
}
);
return arr
}
function CustomField(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, CheckboxesWidget} = registry.widgets
if (!schema.fetch_url){
if (!schema.permissions){
return <TextField onChange={(event)=>(props.onChange(event.target.value))} value={props.value} label={props.label}/>
}
return <CheckboxesWidget
{...newProps}
onChange={(event)=>{
props.onChange(event.reduce((partialSum: number, a: number) => (partialSum + a), 0))
}
}
schema={{}}
options={{
enumOptions: [
{label: 'Start', value: Permission.Start},
{label: 'Stop', value: Permission.Stop},
{label: 'Browse', value: Permission.Browse},
{label: 'Delete', value: Permission.Delete},
{label: 'Run Command', value: Permission.RunCommand},
{label: 'Create', value: Permission.Create},
{label: 'Admin', value: Permission.Admin},
{label: 'Cloud', value: Permission.Cloud},
]}} registry={registry} value={convertNumber(props.value)} />
}
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 & Permission.Admin) === Permission.Admin
if (isAdmin){
return true
}
if (!action.permissions){
return true
}
if ((action.permissions & user.Permissions) == action.permissions){
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 [terminal, setTerminal] = useState(null as string|null);
const [formData, setFormData]: [RJSFSchema, Dispatch<RJSFSchema>] = useState({})
const url = p.action.endpoint.replaceAll(`{${identifierSubstring}}`, actionIdentifier)
function handleSubmit() {
let promise: Promise<AxiosResponse<any, any>>|null = null
switch (p.action.requestType) {
case 'post': {
promise = api.post(url, formData)
break
}
case 'patch':{
promise = api.patch(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)
}
function createTerminal(websocket: string){
const window = open('', '', 'width=800 height=600')!
window.document.write('<div id="root" width="100%" height="100%"/>')
const root = ReactDOM.createRoot(
window.document.getElementById('root') as HTMLElement
);
root.render(<Box><TerminalComponent websocket={websocket}/></Box>)
}
return (<>
<Button variant={p.variant} disabled={!isUserAllowed(user, p.action)} onClick={() => { if (p.onClick) { p.onClick() } p.action.response_action == 'Terminal'?setTerminal(`ws${API_URL.slice("http".length)}${url}`):setForm(true) }} sx={p.sx}>{p.action.name}</Button >
<Modal
onClose={() => { setForm(false); setFormData({}); }}
open={form}
>
<Box sx={{ ...formModalStyle}}>
<Form validator={validator} widgets={{TextWidget: CustomField}} schema={p.action.args} onChange={onFormChange} formData={formData} onSubmit={handleSubmit} />
</Box>
</Modal>
<Modal
onClose={() => { setTerminal(null); }}
open={terminal != null}
>
<Box sx={terminalModalStyle}>
<TerminalComponent websocket={terminal} />
</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) => {
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>
}