fix: migrate most components to functional components

Co-authored-by: Matthias Fechner <matthias@fechner.net>
This commit is contained in:
Jannis Mattheis 2025-08-03 21:38:53 +02:00
parent 734113d187
commit 0ca5156fed
21 changed files with 1397 additions and 1622 deletions

View File

@ -7,94 +7,72 @@ import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import {NumberField} from '../common/NumberField'; import {NumberField} from '../common/NumberField';
import React, {Component} from 'react'; import React, {useState} from 'react';
interface IProps { interface IProps {
fClose: VoidFunction; fClose: VoidFunction;
fOnSubmit: (name: string, description: string, defaultPriority: number) => void; fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise<void>;
} }
interface IState { export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => {
name: string; const [name, setName] = useState('');
description: string; const [description, setDescription] = useState('');
defaultPriority: number; const [defaultPriority, setDefaultPriority] = useState(0);
}
export default class AddDialog extends Component<IProps, IState> { const submitEnabled = name.length !== 0;
public state = {name: '', description: '', defaultPriority: 0}; const submitAndClose = async () => {
await fOnSubmit(name, description, defaultPriority);
fClose();
};
public render() { return (
const {fClose, fOnSubmit} = this.props; <Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" id="app-dialog">
const {name, description, defaultPriority} = this.state; <DialogTitle id="form-dialog-title">Create an application</DialogTitle>
const submitEnabled = this.state.name.length !== 0; <DialogContent>
const submitAndClose = () => { <DialogContentText>An application is allowed to send messages.</DialogContentText>
fOnSubmit(name, description, defaultPriority); <TextField
fClose(); autoFocus
}; margin="dense"
return ( className="name"
<Dialog label="Name *"
open={true} type="text"
onClose={fClose} value={name}
aria-labelledby="form-dialog-title" onChange={(e) => setName(e.target.value)}
id="app-dialog"> fullWidth
<DialogTitle id="form-dialog-title">Create an application</DialogTitle> />
<DialogContent> <TextField
<DialogContentText> margin="dense"
An application is allowed to send messages. className="description"
</DialogContentText> label="Short Description"
<TextField value={description}
autoFocus onChange={(e) => setDescription(e.target.value)}
margin="dense" fullWidth
className="name" multiline
label="Name *" />
type="text" <NumberField
value={name} margin="dense"
onChange={this.handleChange.bind(this, 'name')} className="priority"
fullWidth label="Default Priority"
/> value={defaultPriority}
<TextField onChange={(value) => setDefaultPriority(value)}
margin="dense" fullWidth
className="description" />
label="Short Description" </DialogContent>
value={description} <DialogActions>
onChange={this.handleChange.bind(this, 'description')} <Button onClick={fClose}>Cancel</Button>
fullWidth <Tooltip title={submitEnabled ? '' : 'name is required'}>
multiline <div>
/> <Button
<NumberField className="create"
margin="dense" disabled={!submitEnabled}
className="priority" onClick={submitAndClose}
label="Default Priority" color="primary"
value={defaultPriority} variant="contained">
onChange={(value) => this.setState({defaultPriority: value})} Create
fullWidth </Button>
/> </div>
</DialogContent> </Tooltip>
<DialogActions> </DialogActions>
<Button onClick={fClose}>Cancel</Button> </Dialog>
<Tooltip title={submitEnabled ? '' : 'name is required'}> );
<div> };
<Button
className="create"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="contained">
Create
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(
propertyName: 'description' | 'name',
event: React.ChangeEvent<HTMLInputElement>
) {
const state = this.state;
state[propertyName] = event.target.value;
this.setState(state);
}
}

View File

@ -1,3 +1,4 @@
import React, {ChangeEvent, useEffect, useRef, useState} from 'react';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
@ -9,144 +10,132 @@ import TableRow from '@mui/material/TableRow';
import Delete from '@mui/icons-material/Delete'; import Delete from '@mui/icons-material/Delete';
import Edit from '@mui/icons-material/Edit'; import Edit from '@mui/icons-material/Edit';
import CloudUpload from '@mui/icons-material/CloudUpload'; import CloudUpload from '@mui/icons-material/CloudUpload';
import React, {ChangeEvent, Component, SFC} from 'react'; import Button from '@mui/material/Button';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
import Button from '@mui/material/Button';
import CopyableSecret from '../common/CopyableSecret'; import CopyableSecret from '../common/CopyableSecret';
import AddApplicationDialog from './AddApplicationDialog'; import {AddApplicationDialog} from './AddApplicationDialog';
import {observer} from 'mobx-react';
import {observable} from 'mobx';
import {inject, Stores} from '../inject';
import * as config from '../config'; import * as config from '../config';
import UpdateDialog from './UpdateApplicationDialog'; import {UpdateApplicationDialog} from './UpdateApplicationDialog';
import {IApplication} from '../types'; import {IApplication} from '../types';
import {LastUsedCell} from '../common/LastUsedCell'; import {LastUsedCell} from '../common/LastUsedCell';
import {useStores} from '../stores';
import {observer} from 'mobx-react-lite';
@observer const Applications = observer(() => {
class Applications extends Component<Stores<'appStore'>> { const {appStore} = useStores();
@observable const apps = appStore.getItems();
private deleteId: number | false = false; const [toDeleteApp, setToDeleteApp] = useState<IApplication>();
@observable const [toUpdateApp, setToUpdateApp] = useState<IApplication>();
private updateId: number | false = false; const [createDialog, setCreateDialog] = useState<boolean>(false);
@observable
private createDialog = false;
private uploadId = -1; const fileInputRef = useRef<HTMLInputElement>(null);
private upload: HTMLInputElement | null = null; const uploadId = useRef(-1);
public componentDidMount = () => this.props.appStore.refresh(); useEffect(() => void appStore.refresh(), []);
public render() { const handleImageUploadClick = (id: number) => {
const { uploadId.current = id;
createDialog, if (fileInputRef.current) {
deleteId, fileInputRef.current.click();
updateId,
props: {appStore},
} = this;
const apps = appStore.getItems();
return (
<DefaultPage
title="Applications"
rightControl={
<Button
id="create-app"
variant="contained"
color="primary"
onClick={() => (this.createDialog = true)}>
Create Application
</Button>
}
maxWidth={1000}>
<Grid size={{xs: 12}}>
<Paper elevation={6} style={{overflowX: 'auto'}}>
<Table id="app-table">
<TableHead>
<TableRow>
<TableCell padding="checkbox" style={{width: 80}} />
<TableCell>Name</TableCell>
<TableCell>Token</TableCell>
<TableCell>Description</TableCell>
<TableCell>Priority</TableCell>
<TableCell>Last Used</TableCell>
<TableCell />
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{apps.map((app: IApplication) => (
<Row
key={app.id}
description={app.description}
defaultPriority={app.defaultPriority}
image={app.image}
name={app.name}
value={app.token}
lastUsed={app.lastUsed}
fUpload={() => this.uploadImage(app.id)}
fDelete={() => (this.deleteId = app.id)}
fEdit={() => (this.updateId = app.id)}
noDelete={app.internal}
/>
))}
</TableBody>
</Table>
<input
ref={(upload) => (this.upload = upload)}
type="file"
style={{display: 'none'}}
onChange={this.onUploadImage}
/>
</Paper>
</Grid>
{createDialog && (
<AddApplicationDialog
fClose={() => (this.createDialog = false)}
fOnSubmit={appStore.create}
/>
)}
{updateId !== false && (
<UpdateDialog
fClose={() => (this.updateId = false)}
fOnSubmit={(name, description, defaultPriority) =>
appStore.update(updateId, name, description, defaultPriority)
}
initialDescription={appStore.getByID(updateId).description}
initialName={appStore.getByID(updateId).name}
initialDefaultPriority={appStore.getByID(updateId).defaultPriority}
/>
)}
{deleteId !== false && (
<ConfirmDialog
title="Confirm Delete"
text={'Delete ' + appStore.getByID(deleteId).name + '?'}
fClose={() => (this.deleteId = false)}
fOnSubmit={() => appStore.remove(deleteId)}
/>
)}
</DefaultPage>
);
}
private uploadImage = (id: number) => {
this.uploadId = id;
if (this.upload) {
this.upload.click();
} }
}; };
private onUploadImage = (e: ChangeEvent<HTMLInputElement>) => { const onUploadImage = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) { if (!file) {
return; return;
} }
if (['image/png', 'image/jpeg', 'image/gif'].indexOf(file.type) !== -1) { if (['image/png', 'image/jpeg', 'image/gif'].indexOf(file.type) !== -1) {
this.props.appStore.uploadImage(this.uploadId, file); appStore.uploadImage(uploadId.current, file);
} else { } else {
alert('Uploaded file must be of type png, jpeg or gif.'); alert('Uploaded file must be of type png, jpeg or gif.');
} }
}; };
}
return (
<DefaultPage
title="Applications"
rightControl={
<Button
id="create-app"
variant="contained"
color="primary"
onClick={() => setCreateDialog(true)}>
Create Application
</Button>
}
maxWidth={1000}>
<Grid size={12}>
<Paper elevation={6} style={{overflowX: 'auto'}}>
<Table id="app-table">
<TableHead>
<TableRow>
<TableCell padding="checkbox" style={{width: 80}} />
<TableCell>Name</TableCell>
<TableCell>Token</TableCell>
<TableCell>Description</TableCell>
<TableCell>Priority</TableCell>
<TableCell>Last Used</TableCell>
<TableCell />
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{apps.map((app: IApplication) => (
<Row
key={app.id}
description={app.description}
defaultPriority={app.defaultPriority}
image={app.image}
name={app.name}
value={app.token}
lastUsed={app.lastUsed}
fUpload={() => handleImageUploadClick(app.id)}
fDelete={() => setToDeleteApp(app)}
fEdit={() => setToUpdateApp(app)}
noDelete={app.internal}
/>
))}
</TableBody>
</Table>
<input
ref={fileInputRef}
type="file"
style={{display: 'none'}}
onChange={onUploadImage}
/>
</Paper>
</Grid>
{createDialog && (
<AddApplicationDialog
fClose={() => setCreateDialog(false)}
fOnSubmit={appStore.create}
/>
)}
{toUpdateApp != null && (
<UpdateApplicationDialog
fClose={() => setToUpdateApp(undefined)}
fOnSubmit={(name, description, defaultPriority) =>
appStore.update(toUpdateApp.id, name, description, defaultPriority)
}
initialDescription={toUpdateApp?.description}
initialName={toUpdateApp?.name}
initialDefaultPriority={toUpdateApp?.defaultPriority}
/>
)}
{toDeleteApp != null && (
<ConfirmDialog
title="Confirm Delete"
text={'Delete ' + toDeleteApp.name + '?'}
fClose={() => setToDeleteApp(undefined)}
fOnSubmit={() => appStore.remove(toDeleteApp.id)}
/>
)}
</DefaultPage>
);
});
interface IRowProps { interface IRowProps {
name: string; name: string;
@ -161,24 +150,24 @@ interface IRowProps {
fEdit: VoidFunction; fEdit: VoidFunction;
} }
const Row: SFC<IRowProps> = observer( const Row = ({
({ name,
name, value,
value, noDelete,
noDelete, description,
description, defaultPriority,
defaultPriority, lastUsed,
lastUsed, fDelete,
fDelete, fUpload,
fUpload, image,
image, fEdit,
fEdit, }: IRowProps) => {
}) => ( return (
<TableRow> <TableRow>
<TableCell padding="normal"> <TableCell padding="normal">
<div style={{display: 'flex'}}> <div style={{display: 'flex'}}>
<img src={config.get('url') + image} alt="app logo" width="40" height="40" /> <img src={config.get('url') + image} alt="app logo" width="40" height="40" />
<IconButton onClick={fUpload} style={{height: 40}} size="large"> <IconButton onClick={fUpload} style={{height: 40}}>
<CloudUpload /> <CloudUpload />
</IconButton> </IconButton>
</div> </div>
@ -193,17 +182,17 @@ const Row: SFC<IRowProps> = observer(
<LastUsedCell lastUsed={lastUsed} /> <LastUsedCell lastUsed={lastUsed} />
</TableCell> </TableCell>
<TableCell align="right" padding="none"> <TableCell align="right" padding="none">
<IconButton onClick={fEdit} className="edit" size="large"> <IconButton onClick={fEdit} className="edit">
<Edit /> <Edit />
</IconButton> </IconButton>
</TableCell> </TableCell>
<TableCell align="right" padding="none"> <TableCell align="right" padding="none">
<IconButton onClick={fDelete} className="delete" disabled={noDelete} size="large"> <IconButton onClick={fDelete} className="delete" disabled={noDelete}>
<Delete /> <Delete />
</IconButton> </IconButton>
</TableCell> </TableCell>
</TableRow> </TableRow>
) );
); };
export default inject('appStore')(Applications); export default Applications;

View File

@ -7,106 +7,81 @@ import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import {NumberField} from '../common/NumberField'; import {NumberField} from '../common/NumberField';
import React, {Component} from 'react'; import React, {useState} from 'react';
interface IProps { interface IProps {
fClose: VoidFunction; fClose: VoidFunction;
fOnSubmit: (name: string, description: string, defaultPriority: number) => void; fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise<void>;
initialName: string; initialName: string;
initialDescription: string; initialDescription: string;
initialDefaultPriority: number; initialDefaultPriority: number;
} }
interface IState { export const UpdateApplicationDialog = ({
name: string; initialName,
description: string; initialDescription,
defaultPriority: number; initialDefaultPriority,
} fClose,
fOnSubmit,
}: IProps) => {
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [defaultPriority, setDefaultPriority] = useState(initialDefaultPriority);
export default class UpdateDialog extends Component<IProps, IState> { const submitEnabled = name.length !== 0;
public state = {name: '', description: '', defaultPriority: 0}; const submitAndClose = async () => {
await fOnSubmit(name, description, defaultPriority);
fClose();
};
constructor(props: IProps) { return (
super(props); <Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" id="app-dialog">
this.state = { <DialogTitle id="form-dialog-title">Update an application</DialogTitle>
name: props.initialName, <DialogContent>
description: props.initialDescription, <DialogContentText>An application is allowed to send messages.</DialogContentText>
defaultPriority: props.initialDefaultPriority, <TextField
}; autoFocus
} margin="dense"
className="name"
public render() { label="Name *"
const {fClose, fOnSubmit} = this.props; type="text"
const {name, description, defaultPriority} = this.state; value={name}
const submitEnabled = this.state.name.length !== 0; onChange={(e) => setName(e.target.value)}
const submitAndClose = () => { fullWidth
fOnSubmit(name, description, defaultPriority); />
fClose(); <TextField
}; margin="dense"
return ( className="description"
<Dialog label="Short Description"
open={true} value={description}
onClose={fClose} onChange={(e) => setDescription(e.target.value)}
aria-labelledby="form-dialog-title" fullWidth
id="app-dialog"> multiline
<DialogTitle id="form-dialog-title">Update an application</DialogTitle> />
<DialogContent> <NumberField
<DialogContentText> margin="dense"
An application is allowed to send messages. className="priority"
</DialogContentText> label="Default Priority"
<TextField value={defaultPriority}
autoFocus onChange={(e) => setDefaultPriority(e)}
margin="dense" fullWidth
className="name" />
label="Name *" </DialogContent>
type="text" <DialogActions>
value={name} <Button onClick={fClose}>Cancel</Button>
onChange={this.handleChange.bind(this, 'name')} <Tooltip title={submitEnabled ? '' : 'name is required'}>
fullWidth <div>
/> <Button
<TextField className="update"
margin="dense" disabled={!submitEnabled}
className="description" onClick={submitAndClose}
label="Short Description" color="primary"
value={description} variant="contained">
onChange={this.handleChange.bind(this, 'description')} Update
fullWidth </Button>
multiline </div>
/> </Tooltip>
<NumberField </DialogActions>
margin="dense" </Dialog>
className="priority" );
label="Default Priority" };
value={defaultPriority}
onChange={(value) => this.setState({defaultPriority: value})}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip title={submitEnabled ? '' : 'name is required'}>
<div>
<Button
className="update"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="contained">
Update
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(
propertyName: 'name' | 'description',
event: React.ChangeEvent<HTMLInputElement>
) {
const state = this.state;
state[propertyName] = event.target.value;
this.setState(state);
}
}

View File

@ -1,3 +1,4 @@
import React, {useState} from 'react';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions'; import DialogActions from '@mui/material/DialogActions';
@ -5,67 +6,53 @@ import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import React, {Component} from 'react';
interface IProps { interface IProps {
fClose: VoidFunction; fClose: VoidFunction;
fOnSubmit: (name: string) => void; fOnSubmit: (name: string) => Promise<void>;
} }
export default class AddDialog extends Component<IProps, {name: string}> { const AddClientDialog = ({fClose, fOnSubmit}: IProps) => {
public state = {name: ''}; const [name, setName] = useState('');
public render() { const submitEnabled = name.length !== 0;
const {fClose, fOnSubmit} = this.props; const submitAndClose = async () => {
const {name} = this.state; await fOnSubmit(name);
const submitEnabled = this.state.name.length !== 0; fClose();
const submitAndClose = () => { };
fOnSubmit(name);
fClose();
};
return (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="client-dialog">
<DialogTitle id="form-dialog-title">Create a client</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
className="name"
label="Name *"
type="email"
value={name}
onChange={this.handleChange.bind(this, 'name')}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip
placement={'bottom-start'}
title={submitEnabled ? '' : 'name is required'}>
<div>
<Button
className="create"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="contained">
Create
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(propertyName: 'name', event: React.ChangeEvent<HTMLInputElement>) { return (
const state = this.state; <Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" id="client-dialog">
state[propertyName] = event.target.value; <DialogTitle id="form-dialog-title">Create a client</DialogTitle>
this.setState(state); <DialogContent>
} <TextField
} autoFocus
margin="dense"
className="name"
label="Name *"
type="email"
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip placement={'bottom-start'} title={submitEnabled ? '' : 'name is required'}>
<div>
<Button
className="create"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="contained">
Create
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
};
export default AddClientDialog;

View File

@ -1,3 +1,4 @@
import React, {useEffect, useState} from 'react';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
@ -8,103 +9,89 @@ import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow'; import TableRow from '@mui/material/TableRow';
import Delete from '@mui/icons-material/Delete'; import Delete from '@mui/icons-material/Delete';
import Edit from '@mui/icons-material/Edit'; import Edit from '@mui/icons-material/Edit';
import React, {Component, SFC} from 'react'; import Button from '@mui/material/Button';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
import Button from '@mui/material/Button';
import AddClientDialog from './AddClientDialog'; import AddClientDialog from './AddClientDialog';
import UpdateDialog from './UpdateClientDialog'; import UpdateClientDialog from './UpdateClientDialog';
import {observer} from 'mobx-react';
import {observable} from 'mobx';
import {inject, Stores} from '../inject';
import {IClient} from '../types'; import {IClient} from '../types';
import CopyableSecret from '../common/CopyableSecret'; import CopyableSecret from '../common/CopyableSecret';
import {LastUsedCell} from '../common/LastUsedCell'; import {LastUsedCell} from '../common/LastUsedCell';
import {observer} from 'mobx-react';
import {useStores} from '../stores';
@observer const Clients = observer(() => {
class Clients extends Component<Stores<'clientStore'>> { const {clientStore} = useStores();
@observable const [toDeleteClient, setToDeleteClient] = useState<IClient>();
private showDialog = false; const [toUpdateClient, setToUpdateClient] = useState<IClient>();
@observable const [createDialog, setCreateDialog] = useState<boolean>(false);
private deleteId: false | number = false; const clients = clientStore.getItems();
@observable
private updateId: false | number = false;
public componentDidMount = () => this.props.clientStore.refresh(); useEffect(() => void clientStore.refresh(), []);
public render() { return (
const { <DefaultPage
deleteId, title="Clients"
updateId, rightControl={
showDialog, <Button
props: {clientStore}, id="create-client"
} = this; variant="contained"
const clients = clientStore.getItems(); color="primary"
onClick={() => setCreateDialog(true)}>
return ( Create Client
<DefaultPage </Button>
title="Clients" }>
rightControl={ <Grid size={12}>
<Button <Paper elevation={6} style={{overflowX: 'auto'}}>
id="create-client" <Table id="client-table">
variant="contained" <TableHead>
color="primary" <TableRow style={{textAlign: 'center'}}>
onClick={() => (this.showDialog = true)}> <TableCell>Name</TableCell>
Create Client <TableCell style={{width: 200}}>Token</TableCell>
</Button> <TableCell>Last Used</TableCell>
}> <TableCell />
<Grid size={{xs: 12}}> <TableCell />
<Paper elevation={6} style={{overflowX: 'auto'}}> </TableRow>
<Table id="client-table"> </TableHead>
<TableHead> <TableBody>
<TableRow style={{textAlign: 'center'}}> {clients.map((client: IClient) => (
<TableCell>Name</TableCell> <Row
<TableCell style={{width: 200}}>Token</TableCell> key={client.id}
<TableCell>Last Used</TableCell> name={client.name}
<TableCell /> value={client.token}
<TableCell /> lastUsed={client.lastUsed}
</TableRow> fEdit={() => setToUpdateClient(client)}
</TableHead> fDelete={() => setToDeleteClient(client)}
<TableBody> />
{clients.map((client: IClient) => ( ))}
<Row </TableBody>
key={client.id} </Table>
name={client.name} </Paper>
value={client.token} </Grid>
lastUsed={client.lastUsed} {createDialog && (
fEdit={() => (this.updateId = client.id)} <AddClientDialog
fDelete={() => (this.deleteId = client.id)} fClose={() => setCreateDialog(false)}
/> fOnSubmit={clientStore.create}
))} />
</TableBody> )}
</Table> {toUpdateClient != null && (
</Paper> <UpdateClientDialog
</Grid> fClose={() => setToUpdateClient(undefined)}
{showDialog && ( fOnSubmit={(name) => clientStore.update(toUpdateClient.id, name)}
<AddClientDialog initialName={toUpdateClient.name}
fClose={() => (this.showDialog = false)} />
fOnSubmit={clientStore.create} )}
/> {toDeleteClient != null && (
)} <ConfirmDialog
{updateId !== false && ( title="Confirm Delete"
<UpdateDialog text={'Delete ' + toDeleteClient.name + '?'}
fClose={() => (this.updateId = false)} fClose={() => setToDeleteClient(undefined)}
fOnSubmit={(name) => clientStore.update(updateId, name)} fOnSubmit={() => clientStore.remove(toDeleteClient.id)}
initialName={clientStore.getByID(updateId).name} />
/> )}
)} </DefaultPage>
{deleteId !== false && ( );
<ConfirmDialog });
title="Confirm Delete"
text={'Delete ' + clientStore.getByID(deleteId).name + '?'}
fClose={() => (this.deleteId = false)}
fOnSubmit={() => clientStore.remove(deleteId)}
/>
)}
</DefaultPage>
);
}
}
interface IRowProps { interface IRowProps {
name: string; name: string;
@ -114,7 +101,7 @@ interface IRowProps {
fDelete: VoidFunction; fDelete: VoidFunction;
} }
const Row: SFC<IRowProps> = ({name, value, lastUsed, fEdit, fDelete}) => ( const Row = ({name, value, lastUsed, fEdit, fDelete}: IRowProps) => (
<TableRow> <TableRow>
<TableCell>{name}</TableCell> <TableCell>{name}</TableCell>
<TableCell> <TableCell>
@ -127,16 +114,16 @@ const Row: SFC<IRowProps> = ({name, value, lastUsed, fEdit, fDelete}) => (
<LastUsedCell lastUsed={lastUsed} /> <LastUsedCell lastUsed={lastUsed} />
</TableCell> </TableCell>
<TableCell align="right" padding="none"> <TableCell align="right" padding="none">
<IconButton onClick={fEdit} className="edit" size="large"> <IconButton onClick={fEdit} className="edit">
<Edit /> <Edit />
</IconButton> </IconButton>
</TableCell> </TableCell>
<TableCell align="right" padding="none"> <TableCell align="right" padding="none">
<IconButton onClick={fDelete} className="delete" size="large"> <IconButton onClick={fDelete} className="delete">
<Delete /> <Delete />
</IconButton> </IconButton>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
export default inject('clientStore')(Clients); export default Clients;

View File

@ -1,3 +1,4 @@
import React, {useState} from 'react';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions'; import DialogActions from '@mui/material/DialogActions';
@ -6,81 +7,58 @@ import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import React, {Component} from 'react';
interface IProps { interface IProps {
fClose: VoidFunction; fClose: VoidFunction;
fOnSubmit: (name: string) => void; fOnSubmit: (name: string) => Promise<void>;
initialName: string; initialName: string;
} }
interface IState { const UpdateClientDialog = ({fClose, fOnSubmit, initialName = ''}: IProps) => {
name: string; const [name, setName] = useState(initialName);
}
export default class UpdateDialog extends Component<IProps, IState> { const submitEnabled = name.length !== 0;
public state = {name: ''}; const submitAndClose = async () => {
await fOnSubmit(name);
fClose();
};
constructor(props: IProps) { return (
super(props); <Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" id="client-dialog">
this.state = { <DialogTitle id="form-dialog-title">Update a Client</DialogTitle>
name: props.initialName, <DialogContent>
}; <DialogContentText>
} A client manages messages, clients, applications and users (with admin
permissions).
</DialogContentText>
<TextField
autoFocus
margin="dense"
className="name"
label="Name *"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip title={submitEnabled ? '' : 'name is required'}>
<div>
<Button
className="update"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="contained">
Update
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
};
public render() { export default UpdateClientDialog;
const {fClose, fOnSubmit} = this.props;
const {name} = this.state;
const submitEnabled = this.state.name.length !== 0;
const submitAndClose = () => {
fOnSubmit(name);
fClose();
};
return (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="client-dialog">
<DialogTitle id="form-dialog-title">Update a Client</DialogTitle>
<DialogContent>
<DialogContentText>
A client manages messages, clients, applications and users (with admin
permissions).
</DialogContentText>
<TextField
autoFocus
margin="dense"
className="name"
label="Name *"
type="text"
value={name}
onChange={this.handleChange.bind(this, 'name')}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip title={submitEnabled ? '' : 'name is required'}>
<div>
<Button
className="update"
disabled={!submitEnabled}
onClick={submitAndClose}
color="primary"
variant="contained">
Update
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(propertyName: 'name', event: React.ChangeEvent<HTMLInputElement>) {
const state = this.state;
state[propertyName] = event.target.value;
this.setState(state);
}
}

View File

@ -1,21 +1,19 @@
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import {withStyles} from 'tss-react/mui'; import {makeStyles} from 'tss-react/mui';
import * as React from 'react'; import * as React from 'react';
const styles = () => const useStyles = makeStyles()(() => ({
({ paper: {
paper: { padding: 16,
padding: 16, },
}, }));
} as const);
interface IProps { interface IProps {
style?: React.CSSProperties; style?: React.CSSProperties;
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
} }
const Container: React.FC<IProps> = ({children, style, ...props}) => { const Container: React.FC<IProps> = ({children, style, ...props}) => {
const classes = withStyles.getClasses(props); const {classes} = useStyles();
return ( return (
<Paper elevation={6} className={classes.paper} style={style}> <Paper elevation={6} className={classes.paper} style={style}>
{children} {children}
@ -23,4 +21,4 @@ const Container: React.FC<IProps> = ({children, style, ...props}) => {
); );
}; };
export default withStyles(Container, styles); export default Container;

View File

@ -3,43 +3,20 @@ import Typography from '@mui/material/Typography';
import Visibility from '@mui/icons-material/Visibility'; import Visibility from '@mui/icons-material/Visibility';
import Copy from '@mui/icons-material/FileCopyOutlined'; import Copy from '@mui/icons-material/FileCopyOutlined';
import VisibilityOff from '@mui/icons-material/VisibilityOff'; import VisibilityOff from '@mui/icons-material/VisibilityOff';
import React, {Component, CSSProperties} from 'react'; import React, {CSSProperties} from 'react';
import {Stores, inject} from '../inject'; import {useStores} from '../stores';
interface IProps { interface IProps {
value: string; value: string;
style?: CSSProperties; style?: CSSProperties;
} }
interface IState { const CopyableSecret = ({value, style}: IProps) => {
visible: boolean; const [visible, setVisible] = React.useState(false);
} const text = visible ? value : '•••••••••••••••';
const {snackManager} = useStores();
class CopyableSecret extends Component<IProps & Stores<'snackManager'>, IState> { const toggleVisibility = () => setVisible((b) => !b);
public state = {visible: false}; const copyToClipboard = async () => {
public render() {
const {value, style} = this.props;
const text = this.state.visible ? value : '•••••••••••••••';
return (
<div style={style}>
<IconButton onClick={this.copyToClipboard} title="Copy to clipboard" size="large">
<Copy />
</IconButton>
<IconButton
onClick={this.toggleVisibility}
className="toggle-visibility"
size="large">
{this.state.visible ? <VisibilityOff /> : <Visibility />}
</IconButton>
<Typography style={{fontFamily: 'monospace', fontSize: 16}}>{text}</Typography>
</div>
);
}
private toggleVisibility = () => this.setState({visible: !this.state.visible});
private copyToClipboard = async () => {
const {snackManager, value} = this.props;
try { try {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
snackManager.snack('Copied to clipboard'); snackManager.snack('Copied to clipboard');
@ -48,6 +25,17 @@ class CopyableSecret extends Component<IProps & Stores<'snackManager'>, IState>
snackManager.snack('Failed to copy to clipboard'); snackManager.snack('Failed to copy to clipboard');
} }
}; };
} return (
<div style={style}>
<IconButton onClick={copyToClipboard} title="Copy to clipboard" size="large">
<Copy />
</IconButton>
<IconButton onClick={toggleVisibility} className="toggle-visibility" size="large">
{visible ? <VisibilityOff /> : <Visibility />}
</IconButton>
<Typography style={{fontFamily: 'monospace', fontSize: 16}}>{text}</Typography>
</div>
);
};
export default inject('snackManager')(CopyableSecret); export default CopyableSecret;

View File

@ -1,48 +1,37 @@
import Fab from '@mui/material/Fab'; import Fab from '@mui/material/Fab';
import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp'; import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp';
import React, {Component} from 'react'; import React from 'react';
class ScrollUpButton extends Component { const ScrollUpButton = () => {
state = { const [state, setState] = React.useState({display: 'none', opacity: 0});
display: 'none', React.useEffect(() => {
opacity: 0, const scrollHandler = () => {
}; const currentScrollPos = window.pageYOffset;
componentDidMount() { const opacity = Math.min(currentScrollPos / 500, 1);
window.addEventListener('scroll', this.scrollHandler); const nextState = {display: currentScrollPos > 0 ? 'inherit' : 'none', opacity};
} if (state.display !== nextState.display || state.opacity !== nextState.opacity) {
setState(nextState);
}
};
window.addEventListener('scroll', scrollHandler);
return () => window.removeEventListener('scroll', scrollHandler);
}, []);
componentWillUnmount() { return (
window.removeEventListener('scroll', this.scrollHandler); <Fab
} color="primary"
style={{
scrollHandler = () => { position: 'fixed',
const currentScrollPos = window.pageYOffset; bottom: '30px',
const opacity = Math.min(currentScrollPos / 500, 1); right: '30px',
const nextState = {display: currentScrollPos > 0 ? 'inherit' : 'none', opacity}; zIndex: 100000,
if (this.state.display !== nextState.display || this.state.opacity !== nextState.opacity) { display: state.display,
this.setState(nextState); opacity: state.opacity,
} }}
}; onClick={() => window.scrollTo(0, 0)}>
<KeyboardArrowUp />
public render() { </Fab>
return ( );
<Fab };
color="primary"
style={{
position: 'fixed',
bottom: '30px',
right: '30px',
zIndex: 100000,
display: this.state.display,
opacity: this.state.opacity,
}}
onClick={this.scrollUp}>
<KeyboardArrowUp />
</Fab>
);
}
private scrollUp = () => window.scrollTo(0, 0);
}
export default ScrollUpButton; export default ScrollUpButton;

View File

@ -1,3 +1,4 @@
import React, {useState} from 'react';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions'; import DialogActions from '@mui/material/DialogActions';
@ -5,64 +6,58 @@ import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import React, {Component} from 'react';
import {observable} from 'mobx';
import {observer} from 'mobx-react'; import {observer} from 'mobx-react';
import {inject, Stores} from '../inject'; import {useStores} from '../stores';
interface IProps { interface IProps {
fClose: VoidFunction; fClose: VoidFunction;
} }
@observer const SettingsDialog = observer(({fClose}: IProps) => {
class SettingsDialog extends Component<IProps & Stores<'currentUser'>> { const [pass, setPass] = useState('');
@observable const {currentUser} = useStores();
private pass = '';
public render() { const submitAndClose = async () => {
const {pass} = this; currentUser.changePassword(pass);
const {fClose, currentUser} = this.props; fClose();
const submitAndClose = () => { };
currentUser.changePassword(pass);
fClose();
};
return (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="changepw-dialog">
<DialogTitle id="form-dialog-title">Change Password</DialogTitle>
<DialogContent>
<TextField
className="newpass"
autoFocus
margin="dense"
type="password"
label="New Password *"
value={pass}
onChange={(e) => (this.pass = e.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip title={pass.length !== 0 ? '' : 'Password is required'}>
<div>
<Button
className="change"
disabled={pass.length === 0}
onClick={submitAndClose}
color="primary"
variant="contained">
Change
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
}
export default inject('currentUser')(SettingsDialog); return (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="changepw-dialog">
<DialogTitle id="form-dialog-title">Change Password</DialogTitle>
<DialogContent>
<TextField
className="newpass"
autoFocus
margin="dense"
type="password"
label="New Password *"
value={pass}
onChange={(e) => setPass(e.target.value)}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip title={pass.length !== 0 ? '' : 'Password is required'}>
<div>
<Button
className="change"
disabled={pass.length === 0}
onClick={submitAndClose}
color="primary"
variant="contained">
Change
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
});
export default SettingsDialog;

View File

@ -15,6 +15,7 @@ import {MessagesStore} from './message/MessagesStore';
import {ClientStore} from './client/ClientStore'; import {ClientStore} from './client/ClientStore';
import {PluginStore} from './plugin/PluginStore'; import {PluginStore} from './plugin/PluginStore';
import {registerReactions} from './reactions'; import {registerReactions} from './reactions';
import {StoreContext} from './stores';
const {port, hostname, protocol, pathname} = window.location; const {port, hostname, protocol, pathname} = window.location;
const slashes = protocol.concat('//'); const slashes = protocol.concat('//');
@ -61,9 +62,11 @@ const initStores = (): StoreMapping => {
}; };
ReactDOM.render( ReactDOM.render(
<InjectProvider stores={stores}> <StoreContext.Provider value={stores}>
<Layout /> <InjectProvider stores={stores}>
</InjectProvider>, <Layout />
</InjectProvider>
</StoreContext.Provider>,
document.getElementById('root') document.getElementById('root')
); );
unregister(); unregister();

View File

@ -2,7 +2,7 @@ import AppBar from '@mui/material/AppBar';
import Button, {ButtonProps} from '@mui/material/Button'; import Button, {ButtonProps} from '@mui/material/Button';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import {Theme} from '@mui/material/styles'; import {Theme} from '@mui/material/styles';
import {withStyles} from 'tss-react/mui'; import {makeStyles} from 'tss-react/mui';
import Toolbar from '@mui/material/Toolbar'; import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import AccountCircle from '@mui/icons-material/AccountCircle'; import AccountCircle from '@mui/icons-material/AccountCircle';
@ -14,62 +14,59 @@ import GitHubIcon from '@mui/icons-material/GitHub';
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from '@mui/icons-material/Menu';
import Apps from '@mui/icons-material/Apps'; import Apps from '@mui/icons-material/Apps';
import SupervisorAccount from '@mui/icons-material/SupervisorAccount'; import SupervisorAccount from '@mui/icons-material/SupervisorAccount';
import React, {Component, CSSProperties} from 'react'; import React, {CSSProperties} from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {observer} from 'mobx-react';
import {useMediaQuery} from '@mui/material'; import {useMediaQuery} from '@mui/material';
const styles = (theme: Theme) => const useStyles = makeStyles()((theme: Theme) => ({
({ appBar: {
appBar: { zIndex: theme.zIndex.drawer + 1,
zIndex: theme.zIndex.drawer + 1, [theme.breakpoints.down('sm')]: {
[theme.breakpoints.down('sm')]: { paddingBottom: 10,
paddingBottom: 10,
},
}, },
toolbar: { },
toolbar: {
justifyContent: 'space-between',
[theme.breakpoints.down('sm')]: {
flexWrap: 'wrap',
},
},
menuButtons: {
display: 'flex',
[theme.breakpoints.down('md')]: {
flex: 1,
},
justifyContent: 'center',
[theme.breakpoints.down('sm')]: {
flexBasis: '100%',
marginTop: 5,
order: 1,
height: 50,
justifyContent: 'space-between', justifyContent: 'space-between',
[theme.breakpoints.down('sm')]: {
flexWrap: 'wrap',
},
},
menuButtons: {
display: 'flex',
[theme.breakpoints.down('md')]: {
flex: 1,
},
justifyContent: 'center',
[theme.breakpoints.down('sm')]: {
flexBasis: '100%',
marginTop: 5,
order: 1,
height: 50,
justifyContent: 'space-between',
alignItems: 'center',
},
},
title: {
[theme.breakpoints.up('md')]: {
flex: 1,
},
display: 'flex',
alignItems: 'center', alignItems: 'center',
}, },
titleName: { },
paddingRight: 10, title: {
[theme.breakpoints.up('md')]: {
flex: 1,
}, },
link: { display: 'flex',
color: 'inherit', alignItems: 'center',
textDecoration: 'none', },
}, titleName: {
} as const); paddingRight: 10,
},
link: {
color: 'inherit',
textDecoration: 'none',
},
}));
interface IProps { interface IProps {
loggedIn: boolean; loggedIn: boolean;
name: string; name: string;
admin: boolean; admin: boolean;
version: string; version: string;
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
toggleTheme: VoidFunction; toggleTheme: VoidFunction;
showSettings: VoidFunction; showSettings: VoidFunction;
logout: VoidFunction; logout: VoidFunction;
@ -77,107 +74,123 @@ interface IProps {
setNavOpen: (open: boolean) => void; setNavOpen: (open: boolean) => void;
} }
@observer const Header = ({
class Header extends Component<IProps> { version,
public render() { name,
const {version, name, loggedIn, admin, toggleTheme, logout, style, setNavOpen} = this.props; loggedIn,
admin,
toggleTheme,
logout,
style,
setNavOpen,
showSettings,
}: IProps) => {
const {classes} = useStyles();
const classes = withStyles.getClasses(this.props); return (
<AppBar
return ( sx={{position: {xs: 'sticky', sm: 'fixed'}}}
<AppBar style={style}
sx={{position: {xs: 'sticky', sm: 'fixed'}}} className={classes.appBar}>
style={style} <Toolbar className={classes.toolbar}>
className={classes.appBar}> <div className={classes.title}>
<Toolbar className={classes.toolbar}> <Link to="/" className={classes.link}>
<div className={classes.title}> <Typography variant="h5" className={classes.titleName} color="inherit">
<Link to="/" className={classes.link}> Gotify
<Typography variant="h5" className={classes.titleName} color="inherit"> </Typography>
Gotify
</Typography>
</Link>
<a
href={'https://github.com/gotify/server/releases/tag/v' + version}
className={classes.link}>
<Typography variant="button" color="inherit">
@{version}
</Typography>
</a>
</div>
{loggedIn && this.renderButtons(name, admin, logout, setNavOpen)}
<div>
<IconButton onClick={toggleTheme} color="inherit" size="large">
<Highlight />
</IconButton>
<a
href="https://github.com/gotify/server"
className={classes.link}
target="_blank"
rel="noopener noreferrer">
<IconButton color="inherit" size="large">
<GitHubIcon />
</IconButton>
</a>
</div>
</Toolbar>
</AppBar>
);
}
private renderButtons(
name: string,
admin: boolean,
logout: VoidFunction,
setNavOpen: (open: boolean) => void
) {
const classes = withStyles.getClasses(this.props);
const {showSettings} = this.props;
return (
<div className={classes.menuButtons}>
<ResponsiveButton
sx={{display: {sm: 'none', xs: 'block'}}}
icon={<MenuIcon />}
onClick={() => setNavOpen(true)}
label="menu"
color="inherit"
/>
{admin && (
<Link className={classes.link} to="/users" id="navigate-users">
<ResponsiveButton
icon={<SupervisorAccount />}
label="users"
color="inherit"
/>
</Link> </Link>
<a
href={'https://github.com/gotify/server/releases/tag/v' + version}
className={classes.link}>
<Typography variant="button" color="inherit">
@{version}
</Typography>
</a>
</div>
{loggedIn && (
<Buttons
admin={admin}
name={name}
logout={logout}
setNavOpen={setNavOpen}
showSettings={showSettings}
/>
)} )}
<Link className={classes.link} to="/applications" id="navigate-apps"> <div>
<ResponsiveButton icon={<Chat />} label="apps" color="inherit" /> <IconButton onClick={toggleTheme} color="inherit" size="large">
<Highlight />
</IconButton>
<a
href="https://github.com/gotify/server"
className={classes.link}
target="_blank"
rel="noopener noreferrer">
<IconButton color="inherit" size="large">
<GitHubIcon />
</IconButton>
</a>
</div>
</Toolbar>
</AppBar>
);
};
const Buttons = ({
showSettings,
name,
admin,
logout,
setNavOpen,
}: {
name: string;
admin: boolean;
logout: VoidFunction;
setNavOpen: (open: boolean) => void;
showSettings: VoidFunction;
}) => {
const {classes} = useStyles();
return (
<div className={classes.menuButtons}>
<ResponsiveButton
sx={{display: {sm: 'none', xs: 'block'}}}
icon={<MenuIcon />}
onClick={() => setNavOpen(true)}
label="menu"
color="inherit"
/>
{admin && (
<Link className={classes.link} to="/users" id="navigate-users">
<ResponsiveButton icon={<SupervisorAccount />} label="users" color="inherit" />
</Link> </Link>
<Link className={classes.link} to="/clients" id="navigate-clients"> )}
<ResponsiveButton icon={<DevicesOther />} label="clients" color="inherit" /> <Link className={classes.link} to="/applications" id="navigate-apps">
</Link> <ResponsiveButton icon={<Chat />} label="apps" color="inherit" />
<Link className={classes.link} to="/plugins" id="navigate-plugins"> </Link>
<ResponsiveButton icon={<Apps />} label="plugins" color="inherit" /> <Link className={classes.link} to="/clients" id="navigate-clients">
</Link> <ResponsiveButton icon={<DevicesOther />} label="clients" color="inherit" />
<ResponsiveButton </Link>
icon={<AccountCircle />} <Link className={classes.link} to="/plugins" id="navigate-plugins">
label={name} <ResponsiveButton icon={<Apps />} label="plugins" color="inherit" />
onClick={showSettings} </Link>
id="changepw" <ResponsiveButton
color="inherit" icon={<AccountCircle />}
/> label={name}
<ResponsiveButton onClick={showSettings}
icon={<ExitToApp />} id="changepw"
label="Logout" color="inherit"
onClick={logout} />
id="logout" <ResponsiveButton
color="inherit" icon={<ExitToApp />}
/> label="Logout"
</div> onClick={logout}
); id="logout"
} color="inherit"
} />
</div>
);
};
const ResponsiveButton: React.FC<{ const ResponsiveButton: React.FC<{
color: 'inherit'; color: 'inherit';
@ -202,4 +215,4 @@ const ResponsiveButton: React.FC<{
); );
}; };
export default withStyles(Header, styles); export default Header;

View File

@ -1,5 +1,5 @@
import {createTheme, ThemeProvider, StyledEngineProvider, Theme} from '@mui/material'; import {createTheme, ThemeProvider, StyledEngineProvider, Theme} from '@mui/material';
import {withStyles} from 'tss-react/mui'; import {makeStyles} from 'tss-react/mui';
import CssBaseline from '@mui/material/CssBaseline'; import CssBaseline from '@mui/material/CssBaseline';
import * as React from 'react'; import * as React from 'react';
import {HashRouter, Redirect, Route, Switch} from 'react-router-dom'; import {HashRouter, Redirect, Route, Switch} from 'react-router-dom';
@ -18,11 +18,10 @@ import Login from '../user/Login';
import Messages from '../message/Messages'; import Messages from '../message/Messages';
import Users from '../user/Users'; import Users from '../user/Users';
import {observer} from 'mobx-react'; import {observer} from 'mobx-react';
import {observable} from 'mobx';
import {inject, Stores} from '../inject';
import {ConnectionErrorBanner} from '../common/ConnectionErrorBanner'; import {ConnectionErrorBanner} from '../common/ConnectionErrorBanner';
import {useStores} from '../stores';
const styles = (theme: Theme) => ({ const useStyles = makeStyles()((theme: Theme) => ({
content: { content: {
margin: '0 auto', margin: '0 auto',
marginTop: 64, marginTop: 64,
@ -32,7 +31,7 @@ const styles = (theme: Theme) => ({
marginTop: 0, marginTop: 0,
}, },
}, },
}); }));
const localStorageThemeKey = 'gotify-theme'; const localStorageThemeKey = 'gotify-theme';
type ThemeKey = 'dark' | 'light'; type ThemeKey = 'dark' | 'light';
@ -52,127 +51,103 @@ const themeMap: Record<ThemeKey, Theme> = {
const isThemeKey = (value: string | null): value is ThemeKey => const isThemeKey = (value: string | null): value is ThemeKey =>
value === 'light' || value === 'dark'; value === 'light' || value === 'dark';
interface LayoutProps { const Layout = observer(() => {
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>; const {
} currentUser: {
loggedIn,
authenticating,
user: {name, admin},
logout,
tryReconnect,
connectionErrorMessage,
},
} = useStores();
const {classes} = useStyles();
const [currentTheme, setCurrentTheme] = React.useState<ThemeKey>(() => {
const stored = window.localStorage.getItem(localStorageThemeKey);
return isThemeKey(stored) ? stored : 'dark';
});
const theme = themeMap[currentTheme];
const loginRoute = () => (loggedIn ? <Redirect to="/" /> : <Login />);
const {version} = config.get('version');
const [navOpen, setNavOpen] = React.useState(false);
const [showSettings, setShowSettings] = React.useState(false);
@observer const toggleTheme = () => {
class Layout extends React.Component<LayoutProps & Stores<'currentUser' | 'snackManager'>> { const next = currentTheme === 'dark' ? 'light' : 'dark';
@observable setCurrentTheme(next);
private currentTheme: ThemeKey = 'dark'; localStorage.setItem(localStorageThemeKey, next);
@observable };
private showSettings = false;
@observable
private navOpen = false;
private setNavOpen(open: boolean) { return (
this.navOpen = open; <StyledEngineProvider injectFirst>
} <ThemeProvider theme={theme}>
<HashRouter>
public componentDidMount() { <div>
const localStorageTheme = window.localStorage.getItem(localStorageThemeKey); {!connectionErrorMessage ? null : (
if (isThemeKey(localStorageTheme)) { <ConnectionErrorBanner
this.currentTheme = localStorageTheme; height={64}
} else { retry={() => tryReconnect()}
window.localStorage.setItem(localStorageThemeKey, this.currentTheme); message={connectionErrorMessage}
} />
} )}
<div style={{display: 'flex', flexDirection: 'column'}}>
public render() { <CssBaseline />
const {showSettings, currentTheme} = this; <Header
const { admin={admin}
currentUser: { name={name}
loggedIn, style={{top: !connectionErrorMessage ? 0 : 64}}
authenticating, version={version}
user: {name, admin}, loggedIn={loggedIn}
logout, toggleTheme={toggleTheme}
tryReconnect, showSettings={() => setShowSettings(true)}
connectionErrorMessage, logout={logout}
}, setNavOpen={setNavOpen}
} = this.props; />
const classes = withStyles.getClasses(this.props); <div style={{display: 'flex'}}>
const theme = themeMap[currentTheme]; <Navigation
const loginRoute = () => (loggedIn ? <Redirect to="/" /> : <Login />);
const {version} = config.get('version');
return (
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<HashRouter>
<div>
{!connectionErrorMessage ? null : (
<ConnectionErrorBanner
height={64}
retry={() => tryReconnect()}
message={connectionErrorMessage}
/>
)}
<div style={{display: 'flex', flexDirection: 'column'}}>
<CssBaseline />
<Header
style={{top: !connectionErrorMessage ? 0 : 64}}
admin={admin}
name={name}
version={version}
loggedIn={loggedIn} loggedIn={loggedIn}
toggleTheme={this.toggleTheme.bind(this)} navOpen={navOpen}
showSettings={() => (this.showSettings = true)} setNavOpen={setNavOpen}
logout={logout}
setNavOpen={this.setNavOpen.bind(this)}
/> />
<div style={{display: 'flex'}}> <main className={classes.content}>
<Navigation <Switch>
loggedIn={loggedIn} {authenticating ? (
navOpen={this.navOpen} <Route path="/">
setNavOpen={this.setNavOpen.bind(this)} <LoadingSpinner />
/> </Route>
<main className={classes.content}> ) : null}
<Switch> <Route exact path="/login" render={loginRoute} />
{authenticating ? ( {loggedIn ? null : <Redirect to="/login" />}
<Route path="/"> <Route exact path="/" component={Messages} />
<LoadingSpinner /> <Route exact path="/messages/:id" component={Messages} />
</Route> <Route
) : null} exact
<Route exact path="/login" render={loginRoute} /> path="/applications"
{loggedIn ? null : <Redirect to="/login" />} component={Applications}
<Route exact path="/" component={Messages} /> />
<Route <Route exact path="/clients" component={Clients} />
exact <Route exact path="/users" component={Users} />
path="/messages/:id" <Route exact path="/plugins" component={Plugins} />
component={Messages} <Route
/> exact
<Route path="/plugins/:id"
exact component={PluginDetailView}
path="/applications" />
component={Applications} </Switch>
/> </main>
<Route exact path="/clients" component={Clients} />
<Route exact path="/users" component={Users} />
<Route exact path="/plugins" component={Plugins} />
<Route
exact
path="/plugins/:id"
component={PluginDetailView}
/>
</Switch>
</main>
</div>
{showSettings && (
<SettingsDialog fClose={() => (this.showSettings = false)} />
)}
<ScrollUpButton />
<SnackBarHandler />
</div> </div>
{showSettings && (
<SettingsDialog fClose={() => setShowSettings(false)} />
)}
<ScrollUpButton />
<SnackBarHandler />
</div> </div>
</HashRouter> </div>
</ThemeProvider> </HashRouter>
</StyledEngineProvider> </ThemeProvider>
); </StyledEngineProvider>
} );
});
private toggleTheme() { export default Layout;
this.currentTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
localStorage.setItem(localStorageThemeKey, this.currentTheme);
}
}
export default withStyles(inject('currentUser', 'snackManager')(Layout), styles);

View File

@ -1,10 +1,9 @@
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import Drawer from '@mui/material/Drawer'; import Drawer from '@mui/material/Drawer';
import {Theme} from '@mui/material/styles'; import {Theme} from '@mui/material/styles';
import React, {Component} from 'react'; import React from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {observer} from 'mobx-react'; import {observer} from 'mobx-react';
import {inject, Stores} from '../inject';
import {mayAllowPermission, requestPermission} from '../snack/browserNotification'; import {mayAllowPermission, requestPermission} from '../snack/browserNotification';
import { import {
Button, Button,
@ -17,108 +16,100 @@ import {
} from '@mui/material'; } from '@mui/material';
import {DrawerProps} from '@mui/material/Drawer/Drawer'; import {DrawerProps} from '@mui/material/Drawer/Drawer';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import {withStyles} from 'tss-react/mui'; import {makeStyles} from 'tss-react/mui';
import {useStores} from '../stores';
const styles = (theme: Theme) => const useStyles = makeStyles()((theme: Theme) => ({
({ root: {
root: { height: '100%',
height: '100%', },
}, drawerPaper: {
drawerPaper: { position: 'relative',
position: 'relative', width: 250,
width: 250, minHeight: '100%',
minHeight: '100%', height: '100vh',
height: '100vh', },
}, // eslint-disable-next-line
// eslint-disable-next-line toolbar: theme.mixins.toolbar as any,
toolbar: theme.mixins.toolbar as any, link: {
link: { color: 'inherit',
color: 'inherit', textDecoration: 'none',
textDecoration: 'none', },
}, }));
} as const);
interface IProps { interface IProps {
loggedIn: boolean; loggedIn: boolean;
navOpen: boolean; navOpen: boolean;
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
setNavOpen: (open: boolean) => void; setNavOpen: (open: boolean) => void;
} }
@observer const Navigation = observer(({loggedIn, navOpen, setNavOpen}: IProps) => {
class Navigation extends Component< const [showRequestNotification, setShowRequestNotification] =
IProps & Stores<'appStore'>, React.useState(mayAllowPermission);
{showRequestNotification: boolean} const {classes} = useStyles();
> { const {appStore} = useStores();
public state = {showRequestNotification: mayAllowPermission()}; const apps = appStore.getItems();
public render() { const userApps =
const {loggedIn, appStore, navOpen, setNavOpen} = this.props; apps.length === 0
const classes = withStyles.getClasses(this.props); ? null
const {showRequestNotification} = this.state; : apps.map((app) => (
const apps = appStore.getItems(); <Link
onClick={() => setNavOpen(false)}
className={`${classes.link} item`}
to={'/messages/' + app.id}
key={app.id}>
<ListItemButton>
<ListItemAvatar style={{minWidth: 42}}>
<Avatar
style={{width: 32, height: 32}}
src={app.image}
variant="square"
/>
</ListItemAvatar>
<ListItemText primary={app.name} />
</ListItemButton>
</Link>
));
const userApps = const placeholderItems = [
apps.length === 0 <ListItemButton disabled key={-1}>
? null <ListItemText primary="Some Server" />
: apps.map((app) => ( </ListItemButton>,
<Link <ListItemButton disabled key={-2}>
onClick={() => setNavOpen(false)} <ListItemText primary="A Raspberry PI" />
className={`${classes.link} item`} </ListItemButton>,
to={'/messages/' + app.id} ];
key={app.id}>
<ListItemButton>
<ListItemAvatar style={{minWidth: 42}}>
<Avatar
style={{width: 32, height: 32}}
src={app.image}
variant="square"
/>
</ListItemAvatar>
<ListItemText primary={app.name} />
</ListItemButton>
</Link>
));
const placeholderItems = [ return (
<ListItemButton disabled key={-1}> <ResponsiveDrawer
<ListItemText primary="Some Server" /> classes={{root: classes.root, paper: classes.drawerPaper}}
</ListItemButton>, navOpen={navOpen}
<ListItemButton disabled key={-2}> setNavOpen={setNavOpen}
<ListItemText primary="A Raspberry PI" /> id="message-navigation">
</ListItemButton>, <div className={classes.toolbar} />
]; <Link className={classes.link} to="/" onClick={() => setNavOpen(false)}>
<ListItemButton disabled={!loggedIn} className="all">
return ( <ListItemText primary="All Messages" />
<ResponsiveDrawer </ListItemButton>
classes={{root: classes.root, paper: classes.drawerPaper}} </Link>
navOpen={navOpen} <Divider />
setNavOpen={setNavOpen} <div>{loggedIn ? userApps : placeholderItems}</div>
id="message-navigation"> <Divider />
<div className={classes.toolbar} /> <Typography align="center" style={{marginTop: 10}}>
<Link className={classes.link} to="/" onClick={() => setNavOpen(false)}> {showRequestNotification ? (
<ListItemButton disabled={!loggedIn} className="all"> <Button
<ListItemText primary="All Messages" /> onClick={() => {
</ListItemButton> requestPermission();
</Link> setShowRequestNotification(false);
<Divider /> }}>
<div>{loggedIn ? userApps : placeholderItems}</div> Enable Notifications
<Divider /> </Button>
<Typography align="center" style={{marginTop: 10}}> ) : null}
{showRequestNotification ? ( </Typography>
<Button </ResponsiveDrawer>
onClick={() => { );
requestPermission(); });
this.setState({showRequestNotification: false});
}}>
Enable Notifications
</Button>
) : null}
</Typography>
</ResponsiveDrawer>
);
}
}
const ResponsiveDrawer: React.FC< const ResponsiveDrawer: React.FC<
DrawerProps & {navOpen: boolean; setNavOpen: (open: boolean) => void} DrawerProps & {navOpen: boolean; setNavOpen: (open: boolean) => void}
@ -140,4 +131,4 @@ const ResponsiveDrawer: React.FC<
</> </>
); );
export default withStyles(inject('appStore')(Navigation), styles); export default Navigation;

View File

@ -1,5 +1,5 @@
import React, {Component} from 'react'; import React from 'react';
import {RouteComponentProps} from 'react-router'; import {useParams} from 'react-router';
import {Markdown} from '../common/Markdown'; import {Markdown} from '../common/Markdown';
import {UnControlled as CodeMirror} from 'react-codemirror2'; import {UnControlled as CodeMirror} from 'react-codemirror2';
import 'codemirror/lib/codemirror.css'; import 'codemirror/lib/codemirror.css';
@ -14,110 +14,80 @@ import Typography from '@mui/material/Typography';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
import * as config from '../config'; import * as config from '../config';
import Container from '../common/Container'; import Container from '../common/Container';
import {inject, Stores} from '../inject';
import {IPlugin} from '../types'; import {IPlugin} from '../types';
import LoadingSpinner from '../common/LoadingSpinner'; import LoadingSpinner from '../common/LoadingSpinner';
import {useStores} from '../stores';
type IProps = RouteComponentProps<{id: string}>; const PluginDetailView = () => {
const {id} = useParams<{id: string}>();
const pluginID = parseInt(id as string, 10);
const {pluginStore} = useStores();
const [currentConfig, setCurrentConfig] = React.useState<string>();
const [displayText, setDisplayText] = React.useState<string>();
interface IState { const pluginInfo = pluginStore.getByIDOrUndefined(pluginID);
displayText: string | null;
currentConfig: string | null;
}
class PluginDetailView extends Component<IProps & Stores<'pluginStore'>, IState> { const refreshFeatures = async () => {
private pluginID: number = parseInt(this.props.match.params.id, 10); await pluginStore.refreshIfMissing(pluginID);
private pluginInfo = () => this.props.pluginStore.getByID(this.pluginID); await Promise.all([refreshConfigurer(), refreshDisplayer()]);
public state: IState = {
displayText: null,
currentConfig: null,
}; };
public componentWillMount() { React.useEffect(() => void refreshFeatures(), [pluginID]);
this.refreshFeatures();
}
public componentWillReceiveProps(nextProps: IProps & Stores<'pluginStore'>) { const refreshConfigurer = async () => {
this.pluginID = parseInt(nextProps.match.params.id, 10); if (pluginInfo?.capabilities.indexOf('configurer') !== -1) {
this.refreshFeatures(); setCurrentConfig(await pluginStore.requestConfig(pluginID));
}
private async refreshFeatures() {
await this.props.pluginStore.refreshIfMissing(this.pluginID);
return await Promise.all([this.refreshConfigurer(), this.refreshDisplayer()]);
}
private async refreshConfigurer() {
const {
props: {pluginStore},
} = this;
if (this.pluginInfo().capabilities.indexOf('configurer') !== -1) {
const response = await pluginStore.requestConfig(this.pluginID);
this.setState({currentConfig: response});
} }
};
const refreshDisplayer = async () => {
if (pluginInfo?.capabilities.indexOf('displayer') !== -1) {
setDisplayText(await pluginStore.requestDisplay(pluginID));
}
};
if (pluginInfo == null) {
return <LoadingSpinner />;
} }
private async refreshDisplayer() { const handleSaveConfig = async (newConfig: string) => {
const { await pluginStore.changeConfig(pluginID, newConfig);
props: {pluginStore}, await refreshFeatures();
} = this; };
if (this.pluginInfo().capabilities.indexOf('displayer') !== -1) {
const response = await pluginStore.requestDisplay(this.pluginID);
this.setState({displayText: response});
}
}
public render() { return (
const pluginInfo = this.props.pluginStore.getByIDOrUndefined(this.pluginID); <DefaultPage title={pluginInfo.name} maxWidth={1000}>
if (pluginInfo === undefined) { <PanelWrapper name={'Plugin Info'} icon={Info}>
return <LoadingSpinner />; <PluginInfo pluginInfo={pluginInfo} />
} </PanelWrapper>
return ( {pluginInfo.capabilities.indexOf('configurer') !== -1 ? (
<DefaultPage title={pluginInfo.name} maxWidth={1000}> <PanelWrapper
<PanelWrapper name={'Plugin Info'} icon={Info}> name={'Configurer'}
<PluginInfo pluginInfo={pluginInfo} /> description={'This is the configuration panel for this plugin.'}
icon={Build}
refresh={refreshConfigurer}>
<ConfigurerPanel
pluginInfo={pluginInfo}
initialConfig={currentConfig != null ? currentConfig : 'Loading...'}
save={handleSaveConfig}
/>
</PanelWrapper> </PanelWrapper>
{pluginInfo.capabilities.indexOf('configurer') !== -1 ? ( ) : null}{' '}
<PanelWrapper {pluginInfo.capabilities.indexOf('displayer') !== -1 ? (
name={'Configurer'} <PanelWrapper
description={'This is the configuration panel for this plugin.'} name={'Displayer'}
icon={Build} description={'This is the information generated by the plugin.'}
refresh={this.refreshConfigurer.bind(this)}> refresh={refreshDisplayer}
<ConfigurerPanel icon={Subject}>
pluginInfo={pluginInfo} <DisplayerPanel
initialConfig={ pluginInfo={pluginInfo}
this.state.currentConfig !== null displayText={displayText != null ? displayText : 'Loading...'}
? this.state.currentConfig />
: 'Loading...' </PanelWrapper>
} ) : null}
save={async (newConfig) => { </DefaultPage>
await this.props.pluginStore.changeConfig(this.pluginID, newConfig); );
await this.refreshFeatures(); };
}}
/>
</PanelWrapper>
) : null}{' '}
{pluginInfo.capabilities.indexOf('displayer') !== -1 ? (
<PanelWrapper
name={'Displayer'}
description={'This is the information generated by the plugin.'}
refresh={this.refreshDisplayer.bind(this)}
icon={Subject}>
<DisplayerPanel
pluginInfo={pluginInfo}
displayText={
this.state.displayText !== null
? this.state.displayText
: 'Loading...'
}
/>
</PanelWrapper>
) : null}
</DefaultPage>
);
}
}
interface IPanelWrapperProps { interface IPanelWrapperProps {
name: string; name: string;
@ -178,49 +148,43 @@ interface IConfigurerPanelProps {
initialConfig: string; initialConfig: string;
save: (newConfig: string) => Promise<void>; save: (newConfig: string) => Promise<void>;
} }
class ConfigurerPanel extends Component<IConfigurerPanelProps, {unsavedChanges: string | null}> { const ConfigurerPanel = ({initialConfig, save}: IConfigurerPanelProps) => {
public state = {unsavedChanges: null}; const [unsavedChanges, setUnsavedChanges] = React.useState<string | null>(null);
return (
public render() { <div>
return ( <CodeMirror
<div> value={initialConfig}
<CodeMirror options={{
value={this.props.initialConfig} mode: 'yaml',
options={{ theme: 'material',
mode: 'yaml', lineNumbers: true,
theme: 'material', }}
lineNumbers: true, onChange={(_, _1, value) => {
}} let newConf: string | null = value;
onChange={(_, _1, value) => { if (value === initialConfig) {
let newConf: string | null = value; newConf = null;
if (value === this.props.initialConfig) {
newConf = null;
}
this.setState({unsavedChanges: newConf});
}}
/>
<br />
<Button
variant="contained"
color="primary"
fullWidth={true}
disabled={
this.state.unsavedChanges === null ||
this.state.unsavedChanges === this.props.initialConfig
} }
className="config-save" setUnsavedChanges(newConf);
onClick={() => { }}
const newConfig = this.state.unsavedChanges; />
this.props.save(newConfig!).then(() => { <br />
this.setState({unsavedChanges: null}); <Button
}); variant="contained"
}}> color="primary"
<Typography variant="button">Save</Typography> fullWidth={true}
</Button> disabled={unsavedChanges === null || unsavedChanges === initialConfig}
</div> className="config-save"
); onClick={() => {
} const newConfig = unsavedChanges;
} save(newConfig!).then(() => {
setUnsavedChanges(null);
});
}}>
<Typography variant="button">Save</Typography>
</Button>
</div>
);
};
interface IDisplayerPanelProps { interface IDisplayerPanelProps {
pluginInfo: IPlugin; pluginInfo: IPlugin;
@ -232,58 +196,57 @@ const DisplayerPanel: React.FC<IDisplayerPanelProps> = ({displayText}) => (
</Typography> </Typography>
); );
class PluginInfo extends Component<{pluginInfo: IPlugin}> { interface IPluginInfo {
public render() { pluginInfo: IPlugin;
const {
props: {
pluginInfo: {name, author, modulePath, website, license, capabilities, id, token},
},
} = this;
return (
<div style={{wordWrap: 'break-word'}}>
{name ? (
<Typography variant="body2" className="name">
Name: <span>{name}</span>
</Typography>
) : null}
{author ? (
<Typography variant="body2" className="author">
Author: <span>{author}</span>
</Typography>
) : null}
<Typography variant="body2" className="module-path">
Module Path: <span>{modulePath}</span>
</Typography>
{website ? (
<Typography variant="body2" className="website">
Website: <span>{website}</span>
</Typography>
) : null}
{license ? (
<Typography variant="body2" className="license">
License: <span>{license}</span>
</Typography>
) : null}
<Typography variant="body2" className="capabilities">
Capabilities: <span>{capabilities.join(', ')}</span>
</Typography>
{capabilities.indexOf('webhooker') !== -1 ? (
<Typography variant="body2">
Custom Route Prefix:{' '}
{((url) => (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="custom-route">
{url}
</a>
))(`${config.get('url')}plugin/${id}/custom/${token}/`)}
</Typography>
) : null}
</div>
);
}
} }
export default inject('pluginStore')(PluginDetailView); const PluginInfo = ({pluginInfo}: IPluginInfo) => {
const {name, author, modulePath, website, license, capabilities, id, token} = pluginInfo;
return (
<div style={{wordWrap: 'break-word'}}>
{name ? (
<Typography variant="body2" className="name">
Name: <span>{name}</span>
</Typography>
) : null}
{author ? (
<Typography variant="body2" className="author">
Author: <span>{author}</span>
</Typography>
) : null}
<Typography variant="body2" className="module-path">
Module Path: <span>{modulePath}</span>
</Typography>
{website ? (
<Typography variant="body2" className="website">
Website: <span>{website}</span>
</Typography>
) : null}
{license ? (
<Typography variant="body2" className="license">
License: <span>{license}</span>
</Typography>
) : null}
<Typography variant="body2" className="capabilities">
Capabilities: <span>{capabilities.join(', ')}</span>
</Typography>
{capabilities.indexOf('webhooker') !== -1 ? (
<Typography variant="body2">
Custom Route Prefix:{' '}
{((url) => (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="custom-route">
{url}
</a>
))(`${config.get('url')}plugin/${id}/custom/${token}/`)}
</Typography>
) : null}
</div>
);
};
export default PluginDetailView;

View File

@ -1,4 +1,4 @@
import React, {Component, SFC} from 'react'; import React, {SFC} from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
@ -12,56 +12,47 @@ import {Switch, Button} from '@mui/material';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
import CopyableSecret from '../common/CopyableSecret'; import CopyableSecret from '../common/CopyableSecret';
import {observer} from 'mobx-react'; import {observer} from 'mobx-react';
import {inject, Stores} from '../inject';
import {IPlugin} from '../types'; import {IPlugin} from '../types';
import {useStores} from '../stores';
@observer const Plugins = observer(() => {
class Plugins extends Component<Stores<'pluginStore'>> { const {pluginStore} = useStores();
public componentDidMount = () => this.props.pluginStore.refresh(); React.useEffect(() => void pluginStore.refresh(), []);
const plugins = pluginStore.getItems();
public render() { return (
const { <DefaultPage title="Plugins" maxWidth={1000}>
props: {pluginStore}, <Grid size={{xs: 12}}>
} = this; <Paper elevation={6} style={{overflowX: 'auto'}}>
const plugins = pluginStore.getItems(); <Table id="plugin-table">
return ( <TableHead>
<DefaultPage title="Plugins" maxWidth={1000}> <TableRow>
<Grid size={{xs: 12}}> <TableCell>ID</TableCell>
<Paper elevation={6} style={{overflowX: 'auto'}}> <TableCell>Enabled</TableCell>
<Table id="plugin-table"> <TableCell>Name</TableCell>
<TableHead> <TableCell>Token</TableCell>
<TableRow> <TableCell>Details</TableCell>
<TableCell>ID</TableCell> </TableRow>
<TableCell>Enabled</TableCell> </TableHead>
<TableCell>Name</TableCell> <TableBody>
<TableCell>Token</TableCell> {plugins.map((plugin: IPlugin) => (
<TableCell>Details</TableCell> <Row
</TableRow> key={plugin.token}
</TableHead> id={plugin.id}
<TableBody> token={plugin.token}
{plugins.map((plugin: IPlugin) => ( name={plugin.name}
<Row enabled={plugin.enabled}
key={plugin.token} fToggleStatus={() =>
id={plugin.id} pluginStore.changeEnabledState(plugin.id, !plugin.enabled)
token={plugin.token} }
name={plugin.name} />
enabled={plugin.enabled} ))}
fToggleStatus={() => </TableBody>
this.props.pluginStore.changeEnabledState( </Table>
plugin.id, </Paper>
!plugin.enabled </Grid>
) </DefaultPage>
} );
/> });
))}
</TableBody>
</Table>
</Paper>
</Grid>
</DefaultPage>
);
}
}
interface IRowProps { interface IRowProps {
id: number; id: number;
@ -96,4 +87,4 @@ const Row: SFC<IRowProps> = observer(({name, id, token, enabled, fToggleStatus})
</TableRow> </TableRow>
)); ));
export default inject('pluginStore')(Plugins); export default Plugins;

28
ui/src/stores.tsx Normal file
View File

@ -0,0 +1,28 @@
import * as React from 'react';
import {UserStore} from './user/UserStore';
import {SnackManager} from './snack/SnackManager';
import {MessagesStore} from './message/MessagesStore';
import {CurrentUser} from './CurrentUser';
import {ClientStore} from './client/ClientStore';
import {AppStore} from './application/AppStore';
import {WebSocketStore} from './message/WebSocketStore';
import {PluginStore} from './plugin/PluginStore';
export interface StoreMapping {
userStore: UserStore;
snackManager: SnackManager;
messagesStore: MessagesStore;
currentUser: CurrentUser;
clientStore: ClientStore;
appStore: AppStore;
pluginStore: PluginStore;
wsStore: WebSocketStore;
}
export const StoreContext = React.createContext<StoreMapping | undefined>(undefined);
export const useStores = (): StoreMapping => {
const mapping = React.useContext(StoreContext);
if (!mapping) throw new Error('uninitialized');
return mapping;
};

View File

@ -7,118 +7,101 @@ import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch'; import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import React, {ChangeEvent, Component} from 'react'; import React from 'react';
interface IProps { interface IProps {
name?: string; name?: string;
admin?: boolean; admin?: boolean;
fClose: VoidFunction; fClose: VoidFunction;
fOnSubmit: (name: string, pass: string, admin: boolean) => void; fOnSubmit: (name: string, pass: string, admin: boolean) => Promise<void>;
isEdit?: boolean; isEdit?: boolean;
} }
interface IState { const AddEditUserDialog = ({
name: string; fClose,
pass: string; fOnSubmit,
admin: boolean; isEdit,
} name: initialName = '',
admin: initialAdmin = false,
}: IProps) => {
const [name, setName] = React.useState(initialName);
const [pass, setPass] = React.useState('');
const [admin, setAdmin] = React.useState(initialAdmin);
export default class AddEditDialog extends Component<IProps, IState> { const namePresent = name.length !== 0;
public state = { const passPresent = pass.length !== 0 || isEdit;
name: this.props.name ?? '', const submitAndClose = async () => {
pass: '', await fOnSubmit(name, pass, admin);
admin: this.props.admin ?? false, fClose();
}; };
return (
public render() { <Dialog
const {fClose, fOnSubmit, isEdit} = this.props; open={true}
const {name, pass, admin} = this.state; onClose={fClose}
const namePresent = this.state.name.length !== 0; aria-labelledby="form-dialog-title"
const passPresent = this.state.pass.length !== 0 || isEdit; id="add-edit-user-dialog">
const submitAndClose = () => { <DialogTitle id="form-dialog-title">
fOnSubmit(name, pass, admin); {isEdit ? 'Edit ' + name : 'Add a user'}
fClose(); </DialogTitle>
}; <DialogContent>
return ( <TextField
<Dialog autoFocus
open={true} margin="dense"
onClose={fClose} className="name"
aria-labelledby="form-dialog-title" label="Username *"
id="add-edit-user-dialog"> value={name}
<DialogTitle id="form-dialog-title"> name="username"
{isEdit ? 'Edit ' + this.props.name : 'Add a user'} id="username"
</DialogTitle> onChange={(e) => setName(e.target.value)}
<DialogContent> fullWidth
<TextField />
autoFocus <TextField
margin="dense" margin="dense"
className="name" className="password"
label="Username *" type="password"
value={name} value={pass}
name="username" fullWidth
id="username" label={isEdit ? 'Password (empty if no change)' : 'Password *'}
onChange={this.handleChange.bind(this, 'name')} name="password"
fullWidth id="password"
/> onChange={(e) => setPass(e.target.value)}
<TextField />
margin="dense" <FormControlLabel
className="password" control={
type="password" <Switch
value={pass} checked={admin}
fullWidth className="admin-rights"
label={isEdit ? 'Password (empty if no change)' : 'Password *'} onChange={(e) => setAdmin(e.target.checked)}
name="password" value="admin"
id="password" />
onChange={this.handleChange.bind(this, 'pass')} }
/> label="has administrator rights"
<FormControlLabel />
control={ </DialogContent>
<Switch <DialogActions>
checked={admin} <Button onClick={fClose}>Cancel</Button>
className="admin-rights" <Tooltip
onChange={this.handleChecked.bind(this, 'admin')} placement={'bottom-start'}
value="admin" title={
/> namePresent
} ? passPresent
label="has administrator rights" ? ''
/> : 'password is required'
</DialogContent> : 'username is required'
<DialogActions> }>
<Button onClick={fClose}>Cancel</Button> <div>
<Tooltip <Button
placement={'bottom-start'} className="save-create"
title={ disabled={!passPresent || !namePresent}
namePresent onClick={submitAndClose}
? passPresent color="primary"
? '' variant="contained">
: 'password is required' {isEdit ? 'Save' : 'Create'}
: 'username is required' </Button>
}> </div>
<div> </Tooltip>
<Button </DialogActions>
className="save-create" </Dialog>
disabled={!passPresent || !namePresent} );
onClick={submitAndClose} };
color="primary" export default AddEditUserDialog;
variant="contained">
{isEdit ? 'Save' : 'Create'}
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(propertyName: 'name' | 'pass', event: ChangeEvent<HTMLInputElement>) {
const state = this.state;
state[propertyName] = event.target.value;
this.setState(state);
}
private handleChecked(propertyName: 'admin', event: ChangeEvent<HTMLInputElement>) {
const state = this.state;
state[propertyName] = event.target.checked;
this.setState(state);
}
}

View File

@ -1,97 +1,85 @@
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import React, {Component, FormEvent} from 'react'; import React from 'react';
import Container from '../common/Container'; import Container from '../common/Container';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
import {observable} from 'mobx';
import {observer} from 'mobx-react';
import {inject, Stores} from '../inject';
import * as config from '../config'; import * as config from '../config';
import RegistrationDialog from './Register'; import RegistrationDialog from './Register';
import {useStores} from '../stores';
import {observer} from 'mobx-react';
@observer const Login = observer(() => {
class Login extends Component<Stores<'currentUser'>> { const [username, setUsername] = React.useState('');
@observable const [password, setPassword] = React.useState('');
private username = ''; const [registerDialog, setRegisterDialog] = React.useState(false);
@observable const {currentUser} = useStores();
private password = ''; const registerButton = () => {
@observable
private registerDialog = false;
public render() {
const {username, password, registerDialog} = this;
return (
<DefaultPage title="Login" rightControl={this.registerButton()} maxWidth={250}>
<Grid size={{xs: 12}} style={{textAlign: 'center'}}>
<Container>
<form onSubmit={this.preventDefault} id="login-form">
<TextField
autoFocus
id="username"
className="name"
label="Username"
name="username"
margin="dense"
autoComplete="username"
value={username}
onChange={(e) => (this.username = e.target.value)}
/>
<TextField
id="password"
type="password"
className="password"
label="Password"
name="password"
margin="normal"
autoComplete="current-password"
value={password}
onChange={(e) => (this.password = e.target.value)}
/>
<Button
type="submit"
variant="contained"
size="large"
className="login"
color="primary"
disabled={!!this.props.currentUser.connectionErrorMessage}
style={{marginTop: 15, marginBottom: 5}}
onClick={this.login}>
Login
</Button>
</form>
</Container>
</Grid>
{registerDialog && (
<RegistrationDialog
fClose={() => (this.registerDialog = false)}
fOnSubmit={this.props.currentUser.register}
/>
)}
</DefaultPage>
);
}
private login = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
this.props.currentUser.login(this.username, this.password);
};
private registerButton = () => {
if (config.get('register')) if (config.get('register'))
return ( return (
<Button <Button
id="register" id="register"
variant="contained" variant="contained"
color="primary" color="primary"
onClick={() => (this.registerDialog = true)}> onClick={() => setRegisterDialog(true)}>
Register Register
</Button> </Button>
); );
else return null; else return null;
}; };
const login = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
currentUser.login(username, password);
};
return (
<DefaultPage title="Login" rightControl={registerButton()} maxWidth={250}>
<Grid size={{xs: 12}} style={{textAlign: 'center'}}>
<Container>
<form onSubmit={(e) => e.preventDefault()} id="login-form">
<TextField
autoFocus
id="username"
className="name"
label="Username"
name="username"
margin="dense"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<TextField
id="password"
type="password"
className="password"
label="Password"
name="password"
margin="normal"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
type="submit"
variant="contained"
size="large"
className="login"
color="primary"
disabled={!!currentUser.connectionErrorMessage}
style={{marginTop: 15, marginBottom: 5}}
onClick={login}>
Login
</Button>
</form>
</Container>
</Grid>
{registerDialog && (
<RegistrationDialog
fClose={() => setRegisterDialog(false)}
fOnSubmit={currentUser.register}
/>
)}
</DefaultPage>
);
});
private preventDefault = (e: FormEvent<HTMLFormElement>) => e.preventDefault(); export default Login;
}
export default inject('currentUser')(Login);

View File

@ -5,7 +5,7 @@ import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import React, {ChangeEvent, Component} from 'react'; import React from 'react';
interface IProps { interface IProps {
name?: string; name?: string;
@ -13,92 +13,85 @@ interface IProps {
fOnSubmit: (name: string, pass: string) => Promise<boolean>; fOnSubmit: (name: string, pass: string) => Promise<boolean>;
} }
interface IState { const RegistrationDialog = ({fClose, fOnSubmit, name: initialName = ''}: IProps) => {
name: string; const [name, setName] = React.useState(initialName);
pass: string; const [pass, setPass] = React.useState('');
} const namePresent = name.length !== 0;
const passPresent = pass.length !== 0;
export default class RegistrationDialog extends Component<IProps, IState> { const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
public state = { setName(e.target.value);
name: '',
pass: '',
}; };
public render() { const handlePassChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const {fClose, fOnSubmit} = this.props; setPass(e.target.value);
const {name, pass} = this.state; };
const namePresent = this.state.name.length !== 0;
const passPresent = this.state.pass.length !== 0;
const submitAndClose = (): void => {
fOnSubmit(name, pass).then((success) => {
if (success) {
fClose();
}
});
};
return (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="add-edit-user-dialog">
<DialogTitle id="form-dialog-title">Registration</DialogTitle>
<DialogContent>
<TextField
autoFocus
id="register-username"
margin="dense"
className="name"
label="Username *"
name="username"
value={name}
autoComplete="username"
onChange={this.handleChange.bind(this, 'name')}
fullWidth
/>
<TextField
id="register-password"
margin="dense"
className="password"
type="password"
value={pass}
fullWidth
label="Password *"
name="password"
autoComplete="new-password"
onChange={this.handleChange.bind(this, 'pass')}
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip
placement={'bottom-start'}
title={
namePresent
? passPresent
? ''
: 'password is required'
: 'username is required'
}>
<div>
<Button
className="save-create"
disabled={!passPresent || !namePresent}
onClick={submitAndClose}
color="primary"
variant="contained">
Register
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
}
private handleChange(propertyName: keyof IState, event: ChangeEvent<HTMLInputElement>) { const submitAndClose = (): void => {
const state = this.state; fOnSubmit(name, pass).then((success) => {
state[propertyName] = event.target.value; if (success) {
this.setState(state); fClose();
} }
} });
};
return (
<Dialog
open={true}
onClose={fClose}
aria-labelledby="form-dialog-title"
id="add-edit-user-dialog">
<DialogTitle id="form-dialog-title">Registration</DialogTitle>
<DialogContent>
<TextField
autoFocus
id="register-username"
margin="dense"
className="name"
label="Username *"
name="username"
value={name}
autoComplete="username"
onChange={handleNameChange}
fullWidth
/>
<TextField
id="register-password"
margin="dense"
className="password"
type="password"
value={pass}
fullWidth
label="Password *"
name="password"
autoComplete="new-password"
onChange={handlePassChange}
/>
</DialogContent>
<DialogActions>
<Button onClick={fClose}>Cancel</Button>
<Tooltip
placement={'bottom-start'}
title={
namePresent
? passPresent
? ''
: 'password is required'
: 'username is required'
}>
<div>
<Button
className="save-create"
disabled={!passPresent || !namePresent}
onClick={submitAndClose}
color="primary"
variant="contained">
Register
</Button>
</div>
</Tooltip>
</DialogActions>
</Dialog>
);
};
export default RegistrationDialog;

View File

@ -8,15 +8,14 @@ import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow'; import TableRow from '@mui/material/TableRow';
import Delete from '@mui/icons-material/Delete'; import Delete from '@mui/icons-material/Delete';
import Edit from '@mui/icons-material/Edit'; import Edit from '@mui/icons-material/Edit';
import React, {Component, SFC} from 'react'; import React from 'react';
import ConfirmDialog from '../common/ConfirmDialog'; import ConfirmDialog from '../common/ConfirmDialog';
import DefaultPage from '../common/DefaultPage'; import DefaultPage from '../common/DefaultPage';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import AddEditDialog from './AddEditUserDialog'; import AddEditDialog from './AddEditUserDialog';
import {observer} from 'mobx-react';
import {observable} from 'mobx';
import {inject, Stores} from '../inject';
import {IUser} from '../types'; import {IUser} from '../types';
import {useStores} from '../stores';
import {observer} from 'mobx-react';
interface IRowProps { interface IRowProps {
name: string; name: string;
@ -25,7 +24,7 @@ interface IRowProps {
fEdit: VoidFunction; fEdit: VoidFunction;
} }
const UserRow: SFC<IRowProps> = ({name, admin, fDelete, fEdit}) => ( const UserRow: React.FC<IRowProps> = ({name, admin, fDelete, fEdit}) => (
<TableRow> <TableRow>
<TableCell>{name}</TableCell> <TableCell>{name}</TableCell>
<TableCell>{admin ? 'Yes' : 'No'}</TableCell> <TableCell>{admin ? 'Yes' : 'No'}</TableCell>
@ -40,87 +39,71 @@ const UserRow: SFC<IRowProps> = ({name, admin, fDelete, fEdit}) => (
</TableRow> </TableRow>
); );
@observer const Users = observer(() => {
class Users extends Component<Stores<'userStore'>> { const [deleteUser, setDeleteUser] = React.useState<IUser>();
@observable const [editUser, setEditUser] = React.useState<IUser>();
private createDialog = false; const [createDialog, setCreateDialog] = React.useState(false);
@observable const {userStore} = useStores();
private deleteId: number | false = false; React.useEffect(() => void userStore.refresh(), []);
@observable const users = userStore.getItems();
private editId: number | false = false; return (
<DefaultPage
title="Users"
rightControl={
<Button
id="create-user"
variant="contained"
color="primary"
onClick={() => setCreateDialog(true)}>
Create User
</Button>
}>
<Grid size={{xs: 12}}>
<Paper elevation={6} style={{overflowX: 'auto'}}>
<Table id="user-table">
<TableHead>
<TableRow style={{textAlign: 'center'}}>
<TableCell>Username</TableCell>
<TableCell>Admin</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{users.map((user: IUser) => (
<UserRow
key={user.id}
name={user.name}
admin={user.admin}
fDelete={() => setDeleteUser(user)}
fEdit={() => setEditUser(user)}
/>
))}
</TableBody>
</Table>
</Paper>
</Grid>
{createDialog && (
<AddEditDialog fClose={() => setCreateDialog(false)} fOnSubmit={userStore.create} />
)}
{editUser && (
<AddEditDialog
fClose={() => setEditUser(undefined)}
fOnSubmit={userStore.update.bind(this, editUser.id)}
name={editUser.name}
admin={editUser.admin}
isEdit={true}
/>
)}
{deleteUser && (
<ConfirmDialog
title="Confirm Delete"
text={'Delete ' + deleteUser.name + '?'}
fClose={() => setDeleteUser(undefined)}
fOnSubmit={() => userStore.remove(deleteUser.id)}
/>
)}
</DefaultPage>
);
});
public componentDidMount = () => this.props.userStore.refresh(); export default Users;
public render() {
const {
deleteId,
editId,
createDialog,
props: {userStore},
} = this;
const users = userStore.getItems();
return (
<DefaultPage
title="Users"
rightControl={
<Button
id="create-user"
variant="contained"
color="primary"
onClick={() => (this.createDialog = true)}>
Create User
</Button>
}>
<Grid size={{xs: 12}}>
<Paper elevation={6} style={{overflowX: 'auto'}}>
<Table id="user-table">
<TableHead>
<TableRow style={{textAlign: 'center'}}>
<TableCell>Username</TableCell>
<TableCell>Admin</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{users.map((user: IUser) => (
<UserRow
key={user.id}
name={user.name}
admin={user.admin}
fDelete={() => (this.deleteId = user.id)}
fEdit={() => (this.editId = user.id)}
/>
))}
</TableBody>
</Table>
</Paper>
</Grid>
{createDialog && (
<AddEditDialog
fClose={() => (this.createDialog = false)}
fOnSubmit={userStore.create}
/>
)}
{editId !== false && (
<AddEditDialog
fClose={() => (this.editId = false)}
fOnSubmit={userStore.update.bind(this, editId)}
name={userStore.getByID(editId).name}
admin={userStore.getByID(editId).admin}
isEdit={true}
/>
)}
{deleteId !== false && (
<ConfirmDialog
title="Confirm Delete"
text={'Delete ' + userStore.getByID(deleteId).name + '?'}
fClose={() => (this.deleteId = false)}
fOnSubmit={() => userStore.remove(deleteId)}
/>
)}
</DefaultPage>
);
}
}
export default inject('userStore')(Users);