use react-hook-form-mui for AddEditDialog too
This commit is contained in:
parent
dc9c62e8bb
commit
58e19265ef
|
@ -3,11 +3,7 @@
|
||||||
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||||
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import {
|
import { FormContainer, PasswordElement } from "react-hook-form-mui";
|
||||||
FormContainer,
|
|
||||||
PasswordElement,
|
|
||||||
PasswordRepeatElement,
|
|
||||||
} from "react-hook-form-mui";
|
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import LoadingButton from "@mui/lab/LoadingButton";
|
import LoadingButton from "@mui/lab/LoadingButton";
|
||||||
import Dialog from "@mui/material/Dialog";
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
@ -18,6 +14,7 @@ import TextField from "@mui/material/TextField";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import * as api from "./api";
|
import * as api from "./api";
|
||||||
import { useSnackbars } from "./snackbars";
|
import { useSnackbars } from "./snackbars";
|
||||||
|
import NewPassword from "./NewPassword";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: api.ToplevelUser;
|
user: api.ToplevelUser;
|
||||||
|
@ -32,12 +29,6 @@ interface Request {
|
||||||
newPassword: string;
|
newPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimum password length, taken from [NIST
|
|
||||||
// guidelines](https://pages.nist.gov/800-63-3/sp800-63b.html), section 5.1.1.
|
|
||||||
// This is enforced on the frontend for now; a user who really wants to violate
|
|
||||||
// the rule can via API request.
|
|
||||||
const MIN_PASSWORD_LENGTH = 8;
|
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
currentPassword: string;
|
currentPassword: string;
|
||||||
newPassword: string;
|
newPassword: string;
|
||||||
|
@ -154,7 +145,6 @@ const ChangePassword = ({ user, open, handleClose }: Props) => {
|
||||||
fullWidth
|
fullWidth
|
||||||
helperText=" "
|
helperText=" "
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PasswordElement
|
<PasswordElement
|
||||||
name="currentPassword"
|
name="currentPassword"
|
||||||
label="Current password"
|
label="Current password"
|
||||||
|
@ -165,32 +155,7 @@ const ChangePassword = ({ user, open, handleClose }: Props) => {
|
||||||
fullWidth
|
fullWidth
|
||||||
helperText=" "
|
helperText=" "
|
||||||
/>
|
/>
|
||||||
<PasswordElement
|
<NewPassword />
|
||||||
name="newPassword"
|
|
||||||
label="New password"
|
|
||||||
variant="filled"
|
|
||||||
required
|
|
||||||
autoComplete="new-password"
|
|
||||||
validation={{
|
|
||||||
minLength: {
|
|
||||||
value: MIN_PASSWORD_LENGTH,
|
|
||||||
message: `Must have at least ${MIN_PASSWORD_LENGTH} characters`,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
helperText=" "
|
|
||||||
/>
|
|
||||||
<PasswordRepeatElement
|
|
||||||
name="confirmNewPassword"
|
|
||||||
label="Confirm new password"
|
|
||||||
variant="filled"
|
|
||||||
type="password"
|
|
||||||
passwordFieldName="newPassword"
|
|
||||||
required
|
|
||||||
autoComplete="new-password"
|
|
||||||
fullWidth
|
|
||||||
helperText=" "
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
// 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 { PasswordElement } from "react-hook-form-mui";
|
||||||
|
import { useWatch } from "react-hook-form";
|
||||||
|
|
||||||
|
// Minimum password length, taken from [NIST
|
||||||
|
// guidelines](https://pages.nist.gov/800-63-3/sp800-63b.html), section 5.1.1.
|
||||||
|
// This is enforced on the frontend for now; a user who really wants to violate
|
||||||
|
// the rule can via API request.
|
||||||
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
|
/// Form elements for setting a new password, shared between the ChangePassword
|
||||||
|
/// dialog (for any user to change their own password) and AddEditDialog
|
||||||
|
/// (for admins to add/edit any user).
|
||||||
|
///
|
||||||
|
/// Does no validation if `!required`; AddEditDialog doesn't care about these
|
||||||
|
/// fields unless the password action is "set" (rather than "leave" or "clear").
|
||||||
|
export default function NewPassword(props: { required?: boolean }) {
|
||||||
|
const required = props.required ?? true;
|
||||||
|
const newPasswordValue = useWatch({ name: "newPassword" });
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PasswordElement
|
||||||
|
name="newPassword"
|
||||||
|
label="New password"
|
||||||
|
variant="filled"
|
||||||
|
required={required}
|
||||||
|
autoComplete="new-password"
|
||||||
|
validation={{
|
||||||
|
validate: (v: string) => {
|
||||||
|
if (!required) {
|
||||||
|
return true;
|
||||||
|
} else if (v.length === 0) {
|
||||||
|
return "New password is required.";
|
||||||
|
} else if (v.length < MIN_PASSWORD_LENGTH) {
|
||||||
|
return `Passwords must have at least ${MIN_PASSWORD_LENGTH} characters.`;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
helperText=" "
|
||||||
|
/>
|
||||||
|
<PasswordElement
|
||||||
|
name="confirmNewPassword"
|
||||||
|
label="Confirm new password"
|
||||||
|
variant="filled"
|
||||||
|
type="password"
|
||||||
|
required={required}
|
||||||
|
autoComplete="new-password"
|
||||||
|
fullWidth
|
||||||
|
helperText=" "
|
||||||
|
validation={{
|
||||||
|
validate: (v: string) => {
|
||||||
|
if (!required) {
|
||||||
|
return true;
|
||||||
|
} else if (v.length === 0) {
|
||||||
|
return "Must confirm new password.";
|
||||||
|
} else if (v !== newPasswordValue) {
|
||||||
|
return "Passwords must match.";
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -2,6 +2,13 @@
|
||||||
// Copyright (C) 2023 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
|
// 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
|
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
|
||||||
|
|
||||||
|
import { useFormContext } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
FormContainer,
|
||||||
|
CheckboxElement,
|
||||||
|
RadioButtonGroup,
|
||||||
|
TextFieldElement,
|
||||||
|
} from "react-hook-form-mui";
|
||||||
import LoadingButton from "@mui/lab/LoadingButton";
|
import LoadingButton from "@mui/lab/LoadingButton";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Dialog from "@mui/material/Dialog";
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
@ -9,19 +16,16 @@ import DialogActions from "@mui/material/DialogActions";
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Radio from "@mui/material/Radio";
|
|
||||||
import RadioGroup from "@mui/material/RadioGroup";
|
|
||||||
import * as api from "../api";
|
import * as api from "../api";
|
||||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
import NewPassword from "../NewPassword";
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import FormLabel from "@mui/material/FormLabel";
|
import FormLabel from "@mui/material/FormLabel";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Checkbox from "@mui/material/Checkbox";
|
import React, { useEffect, useState } from "react";
|
||||||
import FormGroup from "@mui/material/FormGroup";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import HelpOutline from "@mui/icons-material/HelpOutline";
|
import HelpOutline from "@mui/icons-material/HelpOutline";
|
||||||
import { useSnackbars } from "../snackbars";
|
import { useSnackbars } from "../snackbars";
|
||||||
|
import Collapse from "@mui/material/Collapse";
|
||||||
|
import FormGroup from "@mui/material/FormGroup";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// UserWithId (for edit), null (for add), undefined (for closed).
|
// UserWithId (for edit), null (for add), undefined (for closed).
|
||||||
|
@ -31,8 +35,6 @@ interface Props {
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PasswordAction = "leave" | "clear" | "set";
|
|
||||||
|
|
||||||
interface PermissionCheckboxDefinition {
|
interface PermissionCheckboxDefinition {
|
||||||
propName: keyof api.Permissions;
|
propName: keyof api.Permissions;
|
||||||
label: string;
|
label: string;
|
||||||
|
@ -57,23 +59,22 @@ const PERMISSION_CHECKBOXES: PermissionCheckboxDefinition[] = [
|
||||||
|
|
||||||
// A group of form controls that's visually separated from the others.
|
// A group of form controls that's visually separated from the others.
|
||||||
interface MyGroupProps {
|
interface MyGroupProps {
|
||||||
labelId: string;
|
labelId?: string;
|
||||||
label: React.ReactNode;
|
label?: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
const MyGroup = ({ label, labelId, children }: MyGroupProps) => (
|
const MyGroup = ({ label, labelId, children }: MyGroupProps) => (
|
||||||
<Box sx={{ mt: 4, mb: 4 }}>
|
<Box sx={{ mt: 4, mb: 4 }}>
|
||||||
<FormLabel id={labelId}>{label}</FormLabel>
|
{label && <FormLabel id={labelId}>{label}</FormLabel>}
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
const PermissionsCheckboxes = (props: {
|
const PermissionsCheckboxes = () => {
|
||||||
permissions: api.Permissions;
|
|
||||||
setPermissions: React.Dispatch<React.SetStateAction<api.Permissions>>;
|
|
||||||
}) => {
|
|
||||||
const checkboxes = PERMISSION_CHECKBOXES.map((def) => (
|
const checkboxes = PERMISSION_CHECKBOXES.map((def) => (
|
||||||
<FormControlLabel
|
<CheckboxElement
|
||||||
|
name={"permissions." + def.propName}
|
||||||
|
key={"permissions." + def.propName}
|
||||||
label={
|
label={
|
||||||
<>
|
<>
|
||||||
{def.label}
|
{def.label}
|
||||||
|
@ -84,22 +85,33 @@ const PermissionsCheckboxes = (props: {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={props.permissions[def.propName]}
|
|
||||||
onChange={(e) => {
|
|
||||||
props.setPermissions((p) => ({
|
|
||||||
...p,
|
|
||||||
[def.propName]: e.target.checked,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
return <>{checkboxes}</>;
|
return <>{checkboxes}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
username: string;
|
||||||
|
passwordAction: "leave" | "clear" | "set";
|
||||||
|
newPassword?: string;
|
||||||
|
permissions: api.Permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MaybeNewPassword = () => {
|
||||||
|
const { watch } = useFormContext<FormData>();
|
||||||
|
const shown = watch("passwordAction") === "set";
|
||||||
|
|
||||||
|
// It'd be nice to focus on the newPassword input when shown,
|
||||||
|
// but react-hook-form-mui's <PasswordElement> uses inputRef for its own
|
||||||
|
// purpose rather than plumbing through one we specify here, so I don't
|
||||||
|
// see an easy way to do it without patching/bypassing that library.
|
||||||
|
return (
|
||||||
|
<Collapse in={shown}>
|
||||||
|
<NewPassword required={shown} />
|
||||||
|
</Collapse>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default function AddEditDialog({
|
export default function AddEditDialog({
|
||||||
prior,
|
prior,
|
||||||
csrf,
|
csrf,
|
||||||
|
@ -108,10 +120,16 @@ export default function AddEditDialog({
|
||||||
}: Props): JSX.Element {
|
}: Props): JSX.Element {
|
||||||
const hasPassword =
|
const hasPassword =
|
||||||
prior !== undefined && prior !== null && prior.user.password !== null;
|
prior !== undefined && prior !== null && prior.user.password !== null;
|
||||||
const [username, setUsername] = useState(prior?.user.username ?? "");
|
const passwordOpts = hasPassword
|
||||||
const [passwordAction, setPasswordAction] = useState<PasswordAction>("leave");
|
? [
|
||||||
const [password, setPassword] = useState("");
|
{ id: "leave", label: "Leave set" },
|
||||||
const [permissions, setPermissions] = useState(prior?.user.permissions ?? {});
|
{ id: "clear", label: "Clear" },
|
||||||
|
{ id: "set", label: "Set a new password" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ id: "leave", label: "Leave unset" },
|
||||||
|
{ id: "set", label: "Set a new password" },
|
||||||
|
];
|
||||||
const [req, setReq] = useState<api.UserSubset | undefined>();
|
const [req, setReq] = useState<api.UserSubset | undefined>();
|
||||||
const snackbars = useSnackbars();
|
const snackbars = useSnackbars();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -134,7 +152,6 @@ export default function AddEditDialog({
|
||||||
{ signal }
|
{ signal }
|
||||||
);
|
);
|
||||||
setReq(undefined);
|
setReq(undefined);
|
||||||
console.log(resp);
|
|
||||||
switch (resp.status) {
|
switch (resp.status) {
|
||||||
case "aborted":
|
case "aborted":
|
||||||
break;
|
break;
|
||||||
|
@ -156,95 +173,63 @@ export default function AddEditDialog({
|
||||||
abort.abort();
|
abort.abort();
|
||||||
};
|
};
|
||||||
}, [prior, req, csrf, snackbars, onClose, refetch]);
|
}, [prior, req, csrf, snackbars, onClose, refetch]);
|
||||||
|
const onSuccess = (data: FormData) => {
|
||||||
|
setReq({
|
||||||
|
username: data.username,
|
||||||
|
password: (() => {
|
||||||
|
switch (data.passwordAction) {
|
||||||
|
case "clear":
|
||||||
|
return null;
|
||||||
|
case "set":
|
||||||
|
return data.newPassword;
|
||||||
|
case "leave":
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
permissions: data.permissions,
|
||||||
|
});
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} maxWidth="md" fullWidth>
|
<Dialog open={true} maxWidth="md" fullWidth>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{prior === null ? "Add user" : `Edit user ${prior.user.username}`}
|
{prior === null ? "Add user" : `Edit user ${prior.user.username}`}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<form
|
<FormContainer<FormData>
|
||||||
onSubmit={() =>
|
defaultValues={{
|
||||||
setReq({
|
username: prior?.user.username,
|
||||||
username: username,
|
passwordAction: "leave",
|
||||||
password:
|
permissions: { ...prior?.user.permissions },
|
||||||
passwordAction === "leave"
|
}}
|
||||||
? undefined
|
onSuccess={onSuccess}
|
||||||
: passwordAction === "set"
|
|
||||||
? password
|
|
||||||
: null,
|
|
||||||
permissions: permissions,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField
|
||||||
id="id"
|
name="id"
|
||||||
label="id"
|
label="id"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
disabled
|
disabled
|
||||||
fullWidth
|
fullWidth
|
||||||
value={prior?.id ?? "(new)"}
|
value={prior?.id ?? "(new)"}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
|
helperText=" "
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextFieldElement
|
||||||
id="username"
|
name="username"
|
||||||
label="Username"
|
label="Username"
|
||||||
|
autoComplete="username"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
|
helperText=" "
|
||||||
/>
|
/>
|
||||||
<MyGroup labelId="password-label" label="Password">
|
<MyGroup>
|
||||||
<RadioGroup
|
<RadioButtonGroup
|
||||||
aria-labelledby="password-label"
|
name="passwordAction"
|
||||||
value={passwordAction}
|
label="Password"
|
||||||
onChange={(e) => {
|
options={passwordOpts}
|
||||||
setPasswordAction(e.target.value as PasswordAction);
|
required
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasPassword && (
|
|
||||||
<>
|
|
||||||
<FormControlLabel
|
|
||||||
value="leave"
|
|
||||||
control={<Radio />}
|
|
||||||
label="Leave set"
|
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
<MaybeNewPassword />
|
||||||
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>
|
||||||
<MyGroup
|
<MyGroup
|
||||||
labelId="permissions-label"
|
labelId="permissions-label"
|
||||||
|
@ -258,17 +243,14 @@ export default function AddEditDialog({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FormGroup aria-labelledby="permissions-label">
|
<FormGroup aria-labelledby="permissions-label">
|
||||||
<PermissionsCheckboxes
|
<PermissionsCheckboxes />
|
||||||
permissions={permissions}
|
|
||||||
setPermissions={setPermissions}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
</MyGroup>
|
</MyGroup>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
loading={false}
|
loading={req !== undefined}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
@ -276,7 +258,7 @@ export default function AddEditDialog({
|
||||||
{prior === null ? "Add" : "Edit"}
|
{prior === null ? "Add" : "Edit"}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</form>
|
</FormContainer>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -253,9 +253,9 @@ export interface UpdateUserRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserSubset {
|
export interface UserSubset {
|
||||||
password?: String | null;
|
password?: string | null;
|
||||||
permissions?: Permissions;
|
permissions?: Permissions;
|
||||||
username?: String;
|
username?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates a user. */
|
/** Creates a user. */
|
||||||
|
|
Loading…
Reference in New Issue