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