Merge remote-tracking branch 'origin/v3.4' into dev

This commit is contained in:
proddy
2022-01-23 17:56:52 +01:00
parent 02e2b51814
commit 77e1898512
538 changed files with 32282 additions and 38655 deletions

View File

@@ -0,0 +1,79 @@
import { FC, useCallback, useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Box,
LinearProgress,
Typography,
TextField,
Button
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { extractErrorMessage } from '../../utils';
import { useSnackbar } from 'notistack';
import { MessageBox } from '../../components';
import * as SecurityApi from '../../api/security';
import { Token } from '../../types';
interface GenerateTokenProps {
username?: string;
onClose: () => void;
}
const GenerateToken: FC<GenerateTokenProps> = ({ username, onClose }) => {
const [token, setToken] = useState<Token>();
const open = !!username;
const { enqueueSnackbar } = useSnackbar();
const getToken = useCallback(async () => {
try {
setToken((await SecurityApi.generateToken(username)).data);
} catch (error: any) {
enqueueSnackbar(extractErrorMessage(error, 'Problem generating token'), { variant: 'error' });
}
}, [username, enqueueSnackbar]);
useEffect(() => {
if (open) {
getToken();
}
}, [open, getToken]);
return (
<Dialog onClose={onClose} aria-labelledby="generate-token-dialog-title" open={!!username} fullWidth maxWidth="sm">
<DialogTitle id="generate-token-dialog-title">Access Token for {username}</DialogTitle>
<DialogContent dividers>
{token ? (
<>
<MessageBox
message="The token below is used with REST API calls that require authorization. It can be passed either as a Bearer token in the
'Authorization' header or in the 'access_token' URL query parameter."
level="info"
my={2}
/>
<Box mt={2} mb={2}>
<TextField label="Token" multiline value={token.token} fullWidth contentEditable={false} />
</Box>
</>
) : (
<Box m={4} textAlign="center">
<LinearProgress />
<Typography variant="h6">Generating token&hellip;</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button startIcon={<CloseIcon />} variant="outlined" onClick={onClose} color="secondary">
Close
</Button>
</DialogActions>
</Dialog>
);
};
export default GenerateToken;

View File

@@ -0,0 +1,176 @@
import { FC, useContext, useState } from 'react';
import { Button, IconButton, Table, TableBody, TableCell, TableFooter, TableHead, TableRow } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import DeleteIcon from '@mui/icons-material/Delete';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import EditIcon from '@mui/icons-material/Edit';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
import * as SecurityApi from '../../api/security';
import { SecuritySettings, User } from '../../types';
import { ButtonRow, FormLoader, MessageBox, SectionContent } from '../../components';
import { createUserValidator } from '../../validators';
import { useRest } from '../../utils';
import { AuthenticatedContext } from '../../contexts/authentication';
import GenerateToken from './GenerateToken';
import UserForm from './UserForm';
function compareUsers(a: User, b: User) {
if (a.username < b.username) {
return -1;
}
if (a.username > b.username) {
return 1;
}
return 0;
}
const ManageUsersForm: FC = () => {
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<SecuritySettings>({
read: SecurityApi.readSecuritySettings,
update: SecurityApi.updateSecuritySettings
});
const [user, setUser] = useState<User>();
const [creating, setCreating] = useState<boolean>(false);
const [generatingToken, setGeneratingToken] = useState<string>();
const authenticatedContext = useContext(AuthenticatedContext);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const noAdminConfigured = () => !data.users.find((u) => u.admin);
const removeUser = (toRemove: User) => {
const users = data.users.filter((u) => u.username !== toRemove.username);
setData({ ...data, users });
};
const createUser = () => {
setCreating(true);
setUser({
username: '',
password: '',
admin: true
});
};
const editUser = (toEdit: User) => {
setCreating(false);
setUser({ ...toEdit });
};
const cancelEditingUser = () => {
setUser(undefined);
};
const doneEditingUser = () => {
if (user) {
const users = [...data.users.filter((u) => u.username !== user.username), user];
setData({ ...data, users });
setUser(undefined);
}
};
const closeGenerateToken = () => {
setGeneratingToken(undefined);
};
const generateToken = (username: string) => {
setGeneratingToken(username);
};
const onSubmit = async () => {
await saveData();
authenticatedContext.refresh();
};
return (
<>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell align="center">is Admin?</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{data.users.sort(compareUsers).map((u) => (
<TableRow key={u.username}>
<TableCell component="th" scope="row">
{u.username}
</TableCell>
<TableCell align="center">{u.admin ? <CheckIcon /> : <CloseIcon />}</TableCell>
<TableCell align="center">
<IconButton
size="small"
disabled={!authenticatedContext.me.admin}
aria-label="Generate Token"
onClick={() => generateToken(u.username)}
>
<VpnKeyIcon />
</IconButton>
<IconButton size="small" aria-label="Delete" onClick={() => removeUser(u)}>
<DeleteIcon />
</IconButton>
<IconButton size="small" aria-label="Edit" onClick={() => editUser(u)}>
<EditIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2} />
<TableCell align="center">
<Button startIcon={<PersonAddIcon />} variant="outlined" color="secondary" onClick={createUser}>
Add
</Button>
</TableCell>
</TableRow>
</TableFooter>
</Table>
{noAdminConfigured() && (
<MessageBox level="warning" message="You must have at least one admin user configured" my={2} />
)}
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving || noAdminConfigured()}
variant="outlined"
color="primary"
type="submit"
onClick={onSubmit}
>
Save
</Button>
</ButtonRow>
<GenerateToken username={generatingToken} onClose={closeGenerateToken} />
<UserForm
user={user}
setUser={setUser}
creating={creating}
onDoneEditing={doneEditingUser}
onCancelEditing={cancelEditingUser}
validator={createUserValidator(data.users, creating)}
/>
</>
);
};
return (
<SectionContent title="Manage Users" titleGutter>
{content()}
</SectionContent>
);
};
export default ManageUsersForm;

View File

@@ -0,0 +1,31 @@
import { FC } from 'react';
import { Navigate, Routes, Route } from 'react-router-dom';
import { Tab } from '@mui/material';
import { RouterTabs, useRouterTab, useLayoutTitle } from '../../components';
import SecuritySettingsForm from './SecuritySettingsForm';
import ManageUsersForm from './ManageUsersForm';
const Security: FC = () => {
useLayoutTitle('Security');
const { routerTab } = useRouterTab();
return (
<>
<RouterTabs value={routerTab}>
<Tab value="users" label="Manage Users" />
<Tab value="settings" label="Security Settings" />
</RouterTabs>
<Routes>
<Route path="users" element={<ManageUsersForm />} />
<Route path="settings" element={<SecuritySettingsForm />} />
<Route path="/*" element={<Navigate replace to="users" />} />
</Routes>
</>
);
};
export default Security;

View File

@@ -0,0 +1,80 @@
import { FC, useContext, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { Button } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import * as SecurityApi from '../../api/security';
import { SecuritySettings } from '../../types';
import { ButtonRow, FormLoader, MessageBox, SectionContent, ValidatedPasswordField } from '../../components';
import { SECURITY_SETTINGS_VALIDATOR, validate } from '../../validators';
import { updateValue, useRest } from '../../utils';
import { AuthenticatedContext } from '../../contexts/authentication';
const SecuritySettingsForm: FC = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<SecuritySettings>({
read: SecurityApi.readSecuritySettings,
update: SecurityApi.updateSecuritySettings
});
const authenticatedContext = useContext(AuthenticatedContext);
const updateFormValue = updateValue(setData);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(SECURITY_SETTINGS_VALIDATOR, data);
await saveData();
await authenticatedContext.refresh();
} catch (errors: any) {
setFieldErrors(errors);
}
};
return (
<>
<ValidatedPasswordField
fieldErrors={fieldErrors}
name="jwt_secret"
label="su Password"
fullWidth
variant="outlined"
value={data.jwt_secret}
onChange={updateFormValue}
margin="normal"
/>
<MessageBox
level="info"
message="The su (super user) password is used to sign authentication tokens and also enable admin privileges within the Console."
mt={1}
/>
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
Save
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="Security Settings" titleGutter>
{content()}
</SectionContent>
);
};
export default SecuritySettingsForm;

View File

@@ -0,0 +1,100 @@
import { FC, useState, useEffect } from 'react';
import Schema, { ValidateFieldsError } from 'async-validator';
import CancelIcon from '@mui/icons-material/Cancel';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import { Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
import { BlockFormControlLabel, ValidatedPasswordField, ValidatedTextField } from '../../components';
import { User } from '../../types';
import { updateValue } from '../../utils';
import { validate } from '../../validators';
interface UserFormProps {
creating: boolean;
validator: Schema;
user?: User;
setUser: React.Dispatch<React.SetStateAction<User | undefined>>;
onDoneEditing: () => void;
onCancelEditing: () => void;
}
const UserForm: FC<UserFormProps> = ({ creating, validator, user, setUser, onDoneEditing, onCancelEditing }) => {
const updateFormValue = updateValue(setUser);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const open = !!user;
useEffect(() => {
if (open) {
setFieldErrors(undefined);
}
}, [open]);
const validateAndDone = async () => {
if (user) {
try {
setFieldErrors(undefined);
await validate(validator, user);
onDoneEditing();
} catch (errors: any) {
setFieldErrors(errors);
}
}
};
return (
<Dialog onClose={onCancelEditing} aria-labelledby="user-form-dialog-title" open={!!user} fullWidth maxWidth="sm">
{user && (
<>
<DialogTitle id="user-form-dialog-title">{creating ? 'Add' : 'Modify'} User</DialogTitle>
<DialogContent dividers>
<ValidatedTextField
fieldErrors={fieldErrors}
name="username"
label="Username"
fullWidth
variant="outlined"
value={user.username}
disabled={!creating}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedPasswordField
fieldErrors={fieldErrors}
name="password"
label="Password"
fullWidth
variant="outlined"
value={user.password}
onChange={updateFormValue}
margin="normal"
/>
<BlockFormControlLabel
control={<Checkbox name="admin" checked={user.admin} onChange={updateFormValue} />}
label="is Admin?"
/>
</DialogContent>
<DialogActions>
<Button startIcon={<CancelIcon />} variant="outlined" onClick={onCancelEditing} color="secondary">
Cancel
</Button>
<Button
startIcon={<PersonAddIcon />}
variant="outlined"
onClick={validateAndDone}
color="primary"
autoFocus
>
Add
</Button>
</DialogActions>
</>
)}
</Dialog>
);
};
export default UserForm;