use react-hook-form-mui for AddEditDialog too

This commit is contained in:
Scott Lamb 2023-01-09 18:16:32 -08:00
parent dc9c62e8bb
commit 58e19265ef
No known key found for this signature in database
4 changed files with 167 additions and 148 deletions

View File

@ -3,11 +3,7 @@
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import { useForm } from "react-hook-form";
import {
FormContainer,
PasswordElement,
PasswordRepeatElement,
} from "react-hook-form-mui";
import { FormContainer, PasswordElement } from "react-hook-form-mui";
import Button from "@mui/material/Button";
import LoadingButton from "@mui/lab/LoadingButton";
import Dialog from "@mui/material/Dialog";
@ -18,6 +14,7 @@ import TextField from "@mui/material/TextField";
import React from "react";
import * as api from "./api";
import { useSnackbars } from "./snackbars";
import NewPassword from "./NewPassword";
interface Props {
user: api.ToplevelUser;
@ -32,12 +29,6 @@ interface Request {
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 {
currentPassword: string;
newPassword: string;
@ -154,7 +145,6 @@ const ChangePassword = ({ user, open, handleClose }: Props) => {
fullWidth
helperText=" "
/>
<PasswordElement
name="currentPassword"
label="Current password"
@ -165,32 +155,7 @@ const ChangePassword = ({ user, open, handleClose }: Props) => {
fullWidth
helperText=" "
/>
<PasswordElement
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=" "
/>
<NewPassword />
</DialogContent>
<DialogActions>

72
ui/src/NewPassword.tsx Normal file
View File

@ -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;
}
},
}}
/>
</>
);
}

View File

@ -2,6 +2,13 @@
// 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 { useFormContext } from "react-hook-form";
import {
FormContainer,
CheckboxElement,
RadioButtonGroup,
TextFieldElement,
} from "react-hook-form-mui";
import LoadingButton from "@mui/lab/LoadingButton";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
@ -9,19 +16,16 @@ 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 NewPassword from "../NewPassword";
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 React, { useEffect, useState } from "react";
import Tooltip from "@mui/material/Tooltip";
import HelpOutline from "@mui/icons-material/HelpOutline";
import { useSnackbars } from "../snackbars";
import Collapse from "@mui/material/Collapse";
import FormGroup from "@mui/material/FormGroup";
interface Props {
// UserWithId (for edit), null (for add), undefined (for closed).
@ -31,8 +35,6 @@ interface Props {
refetch: () => void;
}
type PasswordAction = "leave" | "clear" | "set";
interface PermissionCheckboxDefinition {
propName: keyof api.Permissions;
label: string;
@ -57,23 +59,22 @@ const PERMISSION_CHECKBOXES: PermissionCheckboxDefinition[] = [
// A group of form controls that's visually separated from the others.
interface MyGroupProps {
labelId: string;
label: React.ReactNode;
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>
{label && <FormLabel id={labelId}>{label}</FormLabel>}
{children}
</Box>
);
const PermissionsCheckboxes = (props: {
permissions: api.Permissions;
setPermissions: React.Dispatch<React.SetStateAction<api.Permissions>>;
}) => {
const PermissionsCheckboxes = () => {
const checkboxes = PERMISSION_CHECKBOXES.map((def) => (
<FormControlLabel
<CheckboxElement
name={"permissions." + def.propName}
key={"permissions." + def.propName}
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}</>;
};
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({
prior,
csrf,
@ -108,10 +120,16 @@ export default function AddEditDialog({
}: 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 passwordOpts = hasPassword
? [
{ id: "leave", label: "Leave set" },
{ 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 snackbars = useSnackbars();
useEffect(() => {
@ -134,7 +152,6 @@ export default function AddEditDialog({
{ signal }
);
setReq(undefined);
console.log(resp);
switch (resp.status) {
case "aborted":
break;
@ -156,95 +173,63 @@ export default function AddEditDialog({
abort.abort();
};
}, [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 (
<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,
})
}
<FormContainer<FormData>
defaultValues={{
username: prior?.user.username,
passwordAction: "leave",
permissions: { ...prior?.user.permissions },
}}
onSuccess={onSuccess}
>
<DialogContent>
<TextField
id="id"
name="id"
label="id"
variant="filled"
disabled
fullWidth
value={prior?.id ?? "(new)"}
InputLabelProps={{ shrink: true }}
helperText=" "
/>
<TextField
id="username"
<TextFieldElement
name="username"
label="Username"
autoComplete="username"
variant="filled"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
fullWidth
helperText=" "
/>
<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"
<MyGroup>
<RadioButtonGroup
name="passwordAction"
label="Password"
options={passwordOpts}
required
/>
<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>
<MaybeNewPassword />
</MyGroup>
<MyGroup
labelId="permissions-label"
@ -258,17 +243,14 @@ export default function AddEditDialog({
}
>
<FormGroup aria-labelledby="permissions-label">
<PermissionsCheckboxes
permissions={permissions}
setPermissions={setPermissions}
/>
<PermissionsCheckboxes />
</FormGroup>
</MyGroup>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<LoadingButton
loading={false}
loading={req !== undefined}
color="secondary"
variant="contained"
type="submit"
@ -276,7 +258,7 @@ export default function AddEditDialog({
{prior === null ? "Add" : "Edit"}
</LoadingButton>
</DialogActions>
</form>
</FormContainer>
</Dialog>
);
}

View File

@ -253,9 +253,9 @@ export interface UpdateUserRequest {
}
export interface UserSubset {
password?: String | null;
password?: string | null;
permissions?: Permissions;
username?: String;
username?: string;
}
/** Creates a user. */