user admin UI

This commit is contained in:
Scott Lamb 2023-01-07 11:52:50 -06:00
parent 8c4e69f772
commit dac0f44ed8
No known key found for this signature in database
7 changed files with 631 additions and 5 deletions

View File

@ -29,6 +29,7 @@ even on minor releases, e.g. `0.7.5` -> `0.7.6`.
permissions.
* `DELETE /users/<id>` endpoint to delete a user
* improved API documentation in [`ref/api.md`](ref/api.md).
* first draft of a web UI for user administration. Rough edges expected!
## 0.7.5 (2022-05-09)

View File

@ -26,13 +26,15 @@ import Login from "./Login";
import { useSnackbars } from "./snackbars";
import ListActivity from "./List";
import AppBar from "@mui/material/AppBar";
import { Routes, Route, Link } from "react-router-dom";
import { Routes, Route, Link, Navigate } from "react-router-dom";
import LiveActivity from "./Live";
import UsersActivity from "./Users";
import Drawer from "@mui/material/Drawer";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemText from "@mui/material/ListItemText";
import ListIcon from "@mui/icons-material/List";
import PeopleIcon from "@mui/icons-material/People";
import Videocam from "@mui/icons-material/Videocam";
import ListItemIcon from "@mui/material/ListItemIcon";
import ChangePassword from "./ChangePassword";
@ -165,6 +167,20 @@ function App() {
</ListItemIcon>
<ListItemText primary="Live view (experimental)" />
</ListItem>
{toplevel?.permissions.adminUsers && (
<ListItem
button
key="users"
onClick={toggleShowMenu}
component={Link}
to="/users"
>
<ListItemIcon>
<PeopleIcon />
</ListItemIcon>
<ListItemText primary="Users" />
</ListItem>
)}
</List>
</Drawer>
<Login
@ -220,6 +236,13 @@ function App() {
path="live"
element={<LiveActivity cameras={toplevel.cameras} Frame={Frame} />}
/>
<Route
path="users"
element={
<UsersActivity Frame={Frame} csrf={toplevel!.user?.session?.csrf} />
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@ -75,8 +75,8 @@ const Row = ({
);
/**
* Creates a <tt>TableHeader</tt> and <tt>TableBody</tt> with a list of videos
* for a given <tt>stream</tt> and <tt>range90k</tt>.
* Creates a <tt>TableBody</tt> with a list of videos for a given
* <tt>stream</tt> and <tt>range90k</tt>.
*
* Attempts to minimize reflows while loading. It leaves the existing content
* (either nothing or a previous range) for a while before displaying a

View File

@ -0,0 +1,282 @@
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import LoadingButton from "@mui/lab/LoadingButton";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import TextField from "@mui/material/TextField";
import Radio from "@mui/material/Radio";
import RadioGroup from "@mui/material/RadioGroup";
import * as api from "../api";
import FormControlLabel from "@mui/material/FormControlLabel";
import Stack from "@mui/material/Stack";
import FormLabel from "@mui/material/FormLabel";
import Box from "@mui/material/Box";
import Checkbox from "@mui/material/Checkbox";
import FormGroup from "@mui/material/FormGroup";
import { useEffect, useState } from "react";
import Tooltip from "@mui/material/Tooltip";
import HelpOutline from "@mui/icons-material/HelpOutline";
import { useSnackbars } from "../snackbars";
interface Props {
// UserWithId (for edit), null (for add), undefined (for closed).
prior: api.UserWithId | null;
csrf?: string;
onClose: () => void;
refetch: () => void;
}
type PasswordAction = "leave" | "clear" | "set";
interface PermissionCheckboxDefinition {
propName: keyof api.Permissions;
label: string;
helpText?: string;
}
const PERMISSION_CHECKBOXES: PermissionCheckboxDefinition[] = [
{ propName: "adminUsers", label: "Administer users" },
{
propName: "readCameraConfigs",
label: "Read camera configs",
helpText:
"Allow reading camera configs, including embedded credentials. Set for trusted users only.",
},
{
propName: "updateSignals",
label: "Update signals",
helpText: "Allow updating 'signals' such as motion detection state.",
},
{ propName: "viewVideo", label: "View video" },
];
// A group of form controls that's visually separated from the others.
interface MyGroupProps {
labelId: string;
label: React.ReactNode;
children: React.ReactNode;
}
const MyGroup = ({ label, labelId, children }: MyGroupProps) => (
<Box sx={{ mt: 4, mb: 4 }}>
<FormLabel id={labelId}>{label}</FormLabel>
{children}
</Box>
);
const PermissionsCheckboxes = (props: {
permissions: api.Permissions;
setPermissions: React.Dispatch<React.SetStateAction<api.Permissions>>;
}) => {
const checkboxes = PERMISSION_CHECKBOXES.map((def) => (
<FormControlLabel
label={
<>
{def.label}
{def.helpText && (
<Tooltip title={def.helpText}>
<HelpOutline />
</Tooltip>
)}
</>
}
control={
<Checkbox
checked={props.permissions[def.propName]}
onChange={(e) => {
props.setPermissions((p) => ({
...p,
[def.propName]: e.target.checked,
}));
}}
/>
}
/>
));
return <>{checkboxes}</>;
};
export default function AddEditDialog({
prior,
csrf,
onClose,
refetch,
}: Props): JSX.Element {
const hasPassword =
prior !== undefined && prior !== null && prior.user.password !== null;
const [username, setUsername] = useState(prior?.user.username ?? "");
const [passwordAction, setPasswordAction] = useState<PasswordAction>("leave");
const [password, setPassword] = useState("");
const [permissions, setPermissions] = useState(prior?.user.permissions ?? {});
const [req, setReq] = useState<api.UserSubset | undefined>();
const snackbars = useSnackbars();
useEffect(() => {
const abort = new AbortController();
const send = async (user: api.UserSubset, signal: AbortSignal) => {
const resp = prior
? await api.updateUser(
prior.id,
{
csrf: csrf,
update: user,
},
{ signal }
)
: await api.postUser(
{
csrf: csrf,
user: user,
},
{ signal }
);
setReq(undefined);
console.log(resp);
switch (resp.status) {
case "aborted":
break;
case "error":
snackbars.enqueue({
message: "Request failed: " + resp.message,
});
break;
case "success":
refetch();
onClose();
break;
}
};
if (req !== undefined) {
send(req, abort.signal);
}
return () => {
abort.abort();
};
}, [prior, req, csrf, snackbars, onClose, refetch]);
return (
<Dialog open={true} maxWidth="md" fullWidth>
<DialogTitle>
{prior === null ? "Add user" : `Edit user ${prior.user.username}`}
</DialogTitle>
<form
onSubmit={() =>
setReq({
username: username,
password:
passwordAction === "leave"
? undefined
: passwordAction === "set"
? password
: null,
permissions: permissions,
})
}
>
<DialogContent>
<TextField
id="id"
label="id"
variant="filled"
disabled
fullWidth
value={prior?.id ?? "(new)"}
InputLabelProps={{ shrink: true }}
/>
<TextField
id="username"
label="Username"
variant="filled"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
fullWidth
/>
<MyGroup labelId="password-label" label="Password">
<RadioGroup
aria-labelledby="password-label"
value={passwordAction}
onChange={(e) => {
setPasswordAction(e.target.value as PasswordAction);
}}
>
{hasPassword && (
<>
<FormControlLabel
value="leave"
control={<Radio />}
label="Leave set"
/>
<FormControlLabel
value="clear"
control={<Radio />}
label="Clear"
/>
</>
)}
{!hasPassword && (
<FormControlLabel
value="leave"
control={<Radio />}
label="Leave unset"
/>
)}
<Stack direction="row">
<FormControlLabel
value="set"
control={<Radio />}
label="Set to"
/>
<TextField
id="set-password"
label="New password"
type="password"
autoComplete="new-password"
variant="filled"
fullWidth
value={password}
// TODO: it'd be nice to allow clicking even when disabled,
// set the password action to "set", and give it focus.
// I tried briefly and couldn't make it work quite right.
disabled={passwordAction !== "set"}
onChange={(e) => setPassword(e.target.value)}
/>
</Stack>
</RadioGroup>
</MyGroup>
<MyGroup
labelId="permissions-label"
label={
<>
Permissions
<Tooltip title="Permissions for new sessions created for this user. Currently changing a user's permissions does not affect existing sessions.">
<HelpOutline />
</Tooltip>
</>
}
>
<FormGroup aria-labelledby="permissions-label">
<PermissionsCheckboxes
permissions={permissions}
setPermissions={setPermissions}
/>
</FormGroup>
</MyGroup>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<LoadingButton
loading={false}
color="secondary"
variant="contained"
type="submit"
>
{prior === null ? "Add" : "Edit"}
</LoadingButton>
</DialogActions>
</form>
</Dialog>
);
}

View File

@ -0,0 +1,84 @@
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import { LoadingButton } from "@mui/lab";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import { useEffect, useState } from "react";
import * as api from "../api";
import { useSnackbars } from "../snackbars";
interface Props {
userToDelete?: api.UserWithId;
csrf?: string;
onClose: () => void;
refetch: () => void;
}
export default function DeleteDialog({
userToDelete,
csrf,
onClose,
refetch,
}: Props): JSX.Element {
const [req, setReq] = useState<undefined | number>();
const snackbars = useSnackbars();
useEffect(() => {
const abort = new AbortController();
const doFetch = async (id: number, signal: AbortSignal) => {
const resp = await api.deleteUser(
id,
{
csrf: csrf,
},
{ signal }
);
setReq(undefined);
switch (resp.status) {
case "aborted":
break;
case "error":
snackbars.enqueue({
message: "Delete failed: " + resp.message,
});
break;
case "success":
refetch();
onClose();
break;
}
};
if (req !== undefined) {
doFetch(req, abort.signal);
}
return () => {
abort.abort();
};
}, [req, csrf, snackbars, onClose, refetch]);
return (
<Dialog open={userToDelete !== undefined}>
<DialogTitle>Delete user {userToDelete?.user.username}</DialogTitle>
<DialogContent>
This will permanently delete the given user and all associated sessions.
There's no undo!
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={req !== undefined}>
Cancel
</Button>
<LoadingButton
loading={req !== undefined}
onClick={() => setReq(userToDelete?.id)}
color="secondary"
variant="contained"
>
Delete
</LoadingButton>
</DialogActions>
</Dialog>
);
}

173
ui/src/Users/index.tsx Normal file
View File

@ -0,0 +1,173 @@
// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2022 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import Alert from "@mui/material/Alert";
import Paper from "@mui/material/Paper";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Skeleton from "@mui/material/Skeleton";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow, { TableRowProps } from "@mui/material/TableRow";
import Typography from "@mui/material/Typography";
import { useEffect, useState } from "react";
import * as api from "../api";
import { FrameProps } from "../App";
import AddIcon from "@mui/icons-material/Add";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import IconButton from "@mui/material/IconButton";
import DeleteDialog from "./DeleteDialog";
import AddEditDialog from "./AddEditDialog";
interface Props {
Frame: (props: FrameProps) => JSX.Element;
csrf?: string;
}
interface RowProps extends TableRowProps {
userId: React.ReactNode;
userName: React.ReactNode;
gutter?: React.ReactNode;
}
/// More menu attached to a particular user row.
interface More {
user: api.UserWithId;
anchor: HTMLElement;
}
const Row = ({ userId, userName, gutter, ...rest }: RowProps) => (
<TableRow {...rest}>
<TableCell align="right">{userId}</TableCell>
<TableCell>{userName}</TableCell>
<TableCell>{gutter}</TableCell>
</TableRow>
);
const Main = ({ Frame, csrf }: Props) => {
const [users, setUsers] = useState<
api.FetchResult<api.UsersResponse> | undefined
>();
const [more, setMore] = useState<undefined | More>();
const [fetchSeq, setFetchSeq] = useState(0);
const [userToEdit, setUserToEdit] = useState<
undefined | null | api.UserWithId
>();
const [deleteUser, setDeleteUser] = useState<undefined | api.UserWithId>();
const refetch = () => setFetchSeq((s) => s + 1);
useEffect(() => {
const abort = new AbortController();
const doFetch = async (signal: AbortSignal) => {
setUsers(await api.users({ signal }));
};
doFetch(abort.signal);
return () => {
abort.abort();
};
}, [fetchSeq]);
return (
<Frame>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<Row
userId="id"
userName="username"
gutter={
<IconButton
aria-label="add"
onClick={(e) => setUserToEdit(null)}
>
<AddIcon />
</IconButton>
}
/>
</TableHead>
<TableBody>
{users === undefined && (
<Row
role="progressbar"
userId={<Skeleton />}
userName={<Skeleton />}
/>
)}
{users?.status === "error" && (
<TableRow>
<TableCell colSpan={3}>
<Alert severity="error">{users.message}</Alert>
</TableCell>
</TableRow>
)}
{users?.status === "success" &&
users.response.users.map((u) => (
<Row
key={u.id}
userId={u.id}
userName={u.user.username}
gutter={
<IconButton
aria-label="more"
onClick={(e) =>
setMore({
user: u,
anchor: e.currentTarget,
})
}
>
<MoreVertIcon />
</IconButton>
}
/>
))}
</TableBody>
</Table>
</TableContainer>
<Menu
anchorEl={more?.anchor}
open={more !== undefined}
onClose={() => setMore(undefined)}
>
<MenuItem
onClick={() => {
setUserToEdit(more?.user);
setMore(undefined);
}}
>
Edit
</MenuItem>
<MenuItem>
<Typography
color="error"
onClick={() => {
setDeleteUser(more?.user);
setMore(undefined);
}}
>
Delete
</Typography>
</MenuItem>
</Menu>
{userToEdit !== undefined && (
<AddEditDialog
prior={userToEdit}
refetch={refetch}
onClose={() => setUserToEdit(undefined)}
csrf={csrf}
/>
)}
<DeleteDialog
userToDelete={deleteUser}
refetch={refetch}
onClose={() => setDeleteUser(undefined)}
csrf={csrf}
/>
</Frame>
);
};
export default Main;

View File

@ -157,10 +157,21 @@ async function json<T>(
export interface ToplevelResponse {
timeZoneName: string;
cameras: Camera[];
// This is not part of the wire API; it's synthesized in `toplevel`.
streams: Map<number, Stream>;
permissions: Permissions;
user: ToplevelUser | undefined;
}
export interface Permissions {
adminUsers?: boolean;
readCameraConfigs?: boolean;
updateSignals?: boolean;
viewVideo?: boolean;
}
export interface ToplevelUser {
name: string;
id: number;
@ -217,6 +228,24 @@ export async function logout(req: LogoutRequest, init: RequestInit) {
});
}
export interface UsersResponse {
users: UserWithId[];
}
export interface UserWithId {
id: number;
user: UserSubset;
}
export async function users(init: RequestInit) {
return await json<UsersResponse>(`/api/users/`, init);
}
export interface PostUserRequest {
csrf?: string;
user: UserSubset;
}
export interface UpdateUserRequest {
csrf?: string;
precondition?: UserSubset;
@ -224,7 +253,21 @@ export interface UpdateUserRequest {
}
export interface UserSubset {
password?: String;
password?: String | null;
permissions?: Permissions;
username?: String;
}
/** Creates a user. */
export async function postUser(req: PostUserRequest, init: RequestInit) {
return await myfetch("/api/users/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(req),
...init,
});
}
/** Updates a user. */
@ -234,7 +277,27 @@ export async function updateUser(
init: RequestInit
) {
return await myfetch(`/api/users/${id}`, {
method: "POST",
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(req),
...init,
});
}
export interface DeleteUserRequest {
csrf?: string;
}
/** Deletes a user. */
export async function deleteUser(
id: number,
req: DeleteUserRequest,
init: RequestInit
) {
return await myfetch(`/api/users/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},