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 // 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>

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

View File

@ -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. */