Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/api/enums/Role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ export enum Role {
Administrator = 'Administrator',
PaymentReviewer = 'PaymentReviewer',
PageModerator = 'PageModerator',
SecurityKeeper = 'SecurityKeeper',
}
25 changes: 25 additions & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,31 @@ const EasyVpn = {
});
},
},
users: {
getAll: (token: string) => {
return api.get<User[]>(`/users`, {
headers: { Authorization: `Bearer ${token}` },
});
},
get: (id: string, token: string) => {
return api.get<User>(`/users/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
},
updateRoles: (roles: Role[], id: string, token: string) => {
return api.put<void>(`/users/${id}/roles`, roles, {
headers: { Authorization: `Bearer ${token}` },
});
},
updatePassword: (pwd: string, id: string, token: string) => {
return api.put<void>(`/users/${id}/password`, pwd, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
},
},
};

export default EasyVpn;
Expand Down
2 changes: 0 additions & 2 deletions frontend/src/api/responses/Auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { Role } from '../enums/Role';
import User from './User';

export default interface Auth extends User {
token: string;
roles: Role[];
}
3 changes: 3 additions & 0 deletions frontend/src/api/responses/User.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Role } from '../enums/Role';

export default interface User {
id: string;
firstName: string;
lastName: string;
login: string;
roles: Role[];
}
20 changes: 11 additions & 9 deletions frontend/src/modules/Header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const Header = (props: { isMobile: () => boolean; toggleNav: () => void }) => {
</Box>
</Button>
<Box sx={{ flexGrow: 1 }}>
{Is(Role.Administrator) && (
{(Is(Role.Administrator) || Is(Role.SecurityKeeper)) && (
<>
<IconButton
size="large"
Expand All @@ -89,14 +89,16 @@ const Header = (props: { isMobile: () => boolean; toggleNav: () => void }) => {
>
Connections
</MenuItem>
<MenuItem
onClick={() => {
navigate('/control/users');
setAnchorAdmin(null);
}}
>
Users
</MenuItem>
{Is(Role.SecurityKeeper) && (
<MenuItem
onClick={() => {
navigate('/control/users');
setAnchorAdmin(null);
}}
>
Users
</MenuItem>
)}
<MenuItem
onClick={() => {
navigate('/control/servers');
Expand Down
152 changes: 152 additions & 0 deletions frontend/src/modules/UserRow/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { CheckBox, CheckBoxOutlineBlank, Save, SyncLock } from '@mui/icons-material';
import {
Autocomplete,
Checkbox,
Chip,
IconButton,
TableCell,
TableRow,
TextField,
Typography,
} from '@mui/material';
import React, { FC, useState } from 'react';

import { Role, User } from '../../api';

interface UserRowProps {
user: User;
currentUserId: string;
onChangeRoles?: (id: string, roles: Role[]) => void;
onChangePassword?: (id: string, pwd: string) => void;
}

const UserRow: FC<UserRowProps> = (props: UserRowProps) => {
const [roles, setRoles] = useState<Role[]>(props.user.roles);
const [newPwd, setNewPwd] = useState<string>();

return (
<TableRow hover>
<TableRow>
<TableCell rowSpan={2} sx={{ padding: '10px' }}>
<Chip
label={props.user.login}
color={
props.currentUserId === props.user.id
? 'primary'
: 'secondary'
}
/>
</TableCell>
<TableCell
sx={{
padding: '10px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: 'min(calc(50vw - 50px), 25vw)',
}}
>
<Typography variant="caption">
{props.user.firstName} {props.user.lastName}
</Typography>
</TableCell>
<TableCell
sx={{
padding: '10px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: 'min(calc(50vw - 50px), 30vw)',
}}
>
<Typography variant="caption">{newPwd || '********'}</Typography>
</TableCell>
<TableCell sx={{ padding: '10px' }}>
<IconButton
sx={{ marginLeft: '5px' }}
onClick={() =>
setNewPwd((x) =>
x ? undefined : Math.random().toString(36).slice(2, 8),
)
}
>
<SyncLock />
</IconButton>
</TableCell>
<TableCell rowSpan={2} sx={{ padding: '10px' }}>
<IconButton
sx={{ marginLeft: '5px' }}
onClick={() => {
!(
props.user.roles.length === roles.length &&
props.user.roles.every((item) => roles.includes(item)) &&
roles.every((item) => props.user.roles.includes(item))
) && props.onChangeRoles?.(props.user.id, roles);
newPwd && props.onChangePassword?.(props.user.id, newPwd);
setNewPwd(undefined);
}}
disabled={
props.user.roles.length === roles.length &&
props.user.roles.every((item) => roles.includes(item)) &&
roles.every((item) => props.user.roles.includes(item)) &&
newPwd === undefined
}
>
<Save />
</IconButton>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={3} sx={{ padding: '10px' }}>
<Autocomplete
multiple
id="checkboxes-tags-demo"
options={Object.values(Role)}
limitTags={3}
disableCloseOnSelect
getOptionLabel={(option) => option}
renderOption={(rprops, option, { selected }) => {
const { ...optionProps } = rprops;
return (
<li {...optionProps}>
<Checkbox
icon={<CheckBoxOutlineBlank fontSize="small" />}
checkedIcon={<CheckBox fontSize="small" />}
style={{ marginRight: 8 }}
checked={selected}
disabled={
option === Role.SecurityKeeper &&
props.user.roles.includes(
Role.SecurityKeeper,
) &&
props.currentUserId === props.user.id
}
/>
{option}
</li>
);
}}
style={{ maxWidth: 'min(calc(100vw - 50px), 55vw)', width: 700 }}
renderInput={(params) => <TextField {...params} label="Roles" />}
value={roles}
onChange={(_, roles) =>
setRoles(
props.user.roles.includes(Role.SecurityKeeper) &&
props.currentUserId === props.user.id
? [
Role.SecurityKeeper,
...roles.filter(
(r) => r !== Role.SecurityKeeper,
),
]
: roles,
)
}
/>
</TableCell>
</TableRow>
</TableRow>
);
};

export default UserRow;
95 changes: 95 additions & 0 deletions frontend/src/pages/UsersControlPage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
Alert,
CircularProgress,
Divider,
LinearProgress,
Paper,
Table,
TableBody,
TableContainer,
Typography,
} from '@mui/material';
import React, { useContext } from 'react';
import { FC } from 'react';

import { Context } from '../..';
import EasyVpn, { ApiError, Role, User } from '../../api';
import CenterBox from '../../components/CenterBox';
import { useRequest, useRequestHandler } from '../../hooks';
import UserRow from '../../modules/UserRow';

const UsersControlPage: FC = () => {
const { Auth } = useContext(Context);

const [data, loading, error] = useRequest<User[], ApiError>(
() => EasyVpn.users.getAll(Auth.getToken()).then((v) => v.data),
[location.pathname],
);
const [rolesHandler, rolesLoading, rolesError] = useRequestHandler<
void,
ApiError,
{ roles: Role[]; id: string }
>(({ roles, id }) =>
EasyVpn.users.updateRoles(roles, id, Auth.getToken()).then((v) => v.data),
);
const [pwdHandler, pwdLoading, pwdError] = useRequestHandler<
void,
ApiError,
{ pwd: string; id: string }
>(({ pwd, id }) =>
EasyVpn.users.updatePassword(pwd, id, Auth.getToken()).then((v) => v.data),
);

if (loading) return <LinearProgress />;

return (
<CenterBox margin={2}>
{error ? (
<Alert severity="error" variant="outlined">
{error.response?.data.title ?? error.message}
</Alert>
) : (
<Paper sx={{ borderRadius: 2, paddingBottom: '10px' }}>
<TableContainer>
<Typography variant="h5" padding={3}>
Users:
</Typography>
<Divider sx={{ borderBottomWidth: '3px' }} />
{(rolesLoading || pwdLoading) && <CircularProgress />}
{rolesError && (
<Alert severity="error" variant="outlined">
{rolesError.response?.data.title ?? rolesError.message}
</Alert>
)}
{pwdError && (
<Alert severity="error" variant="outlined">
{pwdError.response?.data.title ?? pwdError.message}
</Alert>
)}
<Table padding="none">
<TableBody>
{data?.map((u, key) => (
<UserRow
key={key}
user={u}
currentUserId={Auth.user.id}
onChangeRoles={(id, roles) =>
rolesHandler(
{ roles, id },
() => (u.roles = roles),
)
}
onChangePassword={(id, pwd) =>
pwdHandler({ pwd, id })
}
/>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
)}
</CenterBox>
);
};
export default UsersControlPage;
3 changes: 2 additions & 1 deletion frontend/src/providers/RoutesProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import NotFoundPage from '../pages/NotFoundPage';
import PaymentTicketsPage from '../pages/PaymentTicketsPage';
import ProfilePage from '../pages/ProfilePage';
import Root from '../pages/Root';
import UsersControlPage from '../pages/UsersControlPage';

interface ForProps {
for: ReactElement;
Expand Down Expand Up @@ -99,7 +100,7 @@ const RoutesProvider: FC = () => {
<Route
path="users"
element={
<Auth with={Role.Administrator} for={<>control users</>} />
<Auth with={Role.SecurityKeeper} for={<UsersControlPage />} />
}
/>
<Route
Expand Down