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.
|
||||
* `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)
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 {
|
||||
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",
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue