mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-04-01 10:13:43 -04:00
user admin UI
This commit is contained in:
parent
8c4e69f772
commit
dac0f44ed8
@ -29,6 +29,7 @@ even on minor releases, e.g. `0.7.5` -> `0.7.6`.
|
|||||||
permissions.
|
permissions.
|
||||||
* `DELETE /users/<id>` endpoint to delete a user
|
* `DELETE /users/<id>` endpoint to delete a user
|
||||||
* improved API documentation in [`ref/api.md`](ref/api.md).
|
* 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)
|
## 0.7.5 (2022-05-09)
|
||||||
|
|
||||||
|
@ -26,13 +26,15 @@ import Login from "./Login";
|
|||||||
import { useSnackbars } from "./snackbars";
|
import { useSnackbars } from "./snackbars";
|
||||||
import ListActivity from "./List";
|
import ListActivity from "./List";
|
||||||
import AppBar from "@mui/material/AppBar";
|
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 LiveActivity from "./Live";
|
||||||
|
import UsersActivity from "./Users";
|
||||||
import Drawer from "@mui/material/Drawer";
|
import Drawer from "@mui/material/Drawer";
|
||||||
import List from "@mui/material/List";
|
import List from "@mui/material/List";
|
||||||
import ListItem from "@mui/material/ListItem";
|
import ListItem from "@mui/material/ListItem";
|
||||||
import ListItemText from "@mui/material/ListItemText";
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
import ListIcon from "@mui/icons-material/List";
|
import ListIcon from "@mui/icons-material/List";
|
||||||
|
import PeopleIcon from "@mui/icons-material/People";
|
||||||
import Videocam from "@mui/icons-material/Videocam";
|
import Videocam from "@mui/icons-material/Videocam";
|
||||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||||
import ChangePassword from "./ChangePassword";
|
import ChangePassword from "./ChangePassword";
|
||||||
@ -165,6 +167,20 @@ function App() {
|
|||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary="Live view (experimental)" />
|
<ListItemText primary="Live view (experimental)" />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
{toplevel?.permissions.adminUsers && (
|
||||||
|
<ListItem
|
||||||
|
button
|
||||||
|
key="users"
|
||||||
|
onClick={toggleShowMenu}
|
||||||
|
component={Link}
|
||||||
|
to="/users"
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<PeopleIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Users" />
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
</List>
|
</List>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
<Login
|
<Login
|
||||||
@ -220,6 +236,13 @@ function App() {
|
|||||||
path="live"
|
path="live"
|
||||||
element={<LiveActivity cameras={toplevel.cameras} Frame={Frame} />}
|
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>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -75,8 +75,8 @@ const Row = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a <tt>TableHeader</tt> and <tt>TableBody</tt> with a list of videos
|
* Creates a <tt>TableBody</tt> with a list of videos for a given
|
||||||
* for a given <tt>stream</tt> and <tt>range90k</tt>.
|
* <tt>stream</tt> and <tt>range90k</tt>.
|
||||||
*
|
*
|
||||||
* Attempts to minimize reflows while loading. It leaves the existing content
|
* Attempts to minimize reflows while loading. It leaves the existing content
|
||||||
* (either nothing or a previous range) for a while before displaying a
|
* (either nothing or a previous range) for a while before displaying a
|
||||||
|
282
ui/src/Users/AddEditDialog.tsx
Normal file
282
ui/src/Users/AddEditDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
84
ui/src/Users/DeleteDialog.tsx
Normal file
84
ui/src/Users/DeleteDialog.tsx
Normal 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
173
ui/src/Users/index.tsx
Normal 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;
|
@ -157,10 +157,21 @@ async function json<T>(
|
|||||||
export interface ToplevelResponse {
|
export interface ToplevelResponse {
|
||||||
timeZoneName: string;
|
timeZoneName: string;
|
||||||
cameras: Camera[];
|
cameras: Camera[];
|
||||||
|
|
||||||
|
// This is not part of the wire API; it's synthesized in `toplevel`.
|
||||||
streams: Map<number, Stream>;
|
streams: Map<number, Stream>;
|
||||||
|
|
||||||
|
permissions: Permissions;
|
||||||
user: ToplevelUser | undefined;
|
user: ToplevelUser | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Permissions {
|
||||||
|
adminUsers?: boolean;
|
||||||
|
readCameraConfigs?: boolean;
|
||||||
|
updateSignals?: boolean;
|
||||||
|
viewVideo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToplevelUser {
|
export interface ToplevelUser {
|
||||||
name: string;
|
name: string;
|
||||||
id: number;
|
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 {
|
export interface UpdateUserRequest {
|
||||||
csrf?: string;
|
csrf?: string;
|
||||||
precondition?: UserSubset;
|
precondition?: UserSubset;
|
||||||
@ -224,7 +253,21 @@ export interface UpdateUserRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UserSubset {
|
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. */
|
/** Updates a user. */
|
||||||
@ -234,7 +277,27 @@ export async function updateUser(
|
|||||||
init: RequestInit
|
init: RequestInit
|
||||||
) {
|
) {
|
||||||
return await myfetch(`/api/users/${id}`, {
|
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: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user