diff --git a/ui/src/ChangePassword.tsx b/ui/src/ChangePassword.tsx index f87bc9a..a81d9e5 100644 --- a/ui/src/ChangePassword.tsx +++ b/ui/src/ChangePassword.tsx @@ -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=" " /> - { fullWidth helperText=" " /> - - + diff --git a/ui/src/NewPassword.tsx b/ui/src/NewPassword.tsx new file mode 100644 index 0000000..e738c39 --- /dev/null +++ b/ui/src/NewPassword.tsx @@ -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 ( + <> + { + 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=" " + /> + { + 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; + } + }, + }} + /> + + ); +} diff --git a/ui/src/Users/AddEditDialog.tsx b/ui/src/Users/AddEditDialog.tsx index eae49b4..4a35f8d 100644 --- a/ui/src/Users/AddEditDialog.tsx +++ b/ui/src/Users/AddEditDialog.tsx @@ -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) => ( - {label} + {label && {label}} {children} ); -const PermissionsCheckboxes = (props: { - permissions: api.Permissions; - setPermissions: React.Dispatch>; -}) => { +const PermissionsCheckboxes = () => { const checkboxes = PERMISSION_CHECKBOXES.map((def) => ( - {def.label} @@ -84,22 +85,33 @@ const PermissionsCheckboxes = (props: { )} } - control={ - { - 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(); + const shown = watch("passwordAction") === "set"; + + // It'd be nice to focus on the newPassword input when shown, + // but react-hook-form-mui's 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 ( + + + + ); +}; + 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("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(); 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 ( {prior === null ? "Add user" : `Edit user ${prior.user.username}`} -
- setReq({ - username: username, - password: - passwordAction === "leave" - ? undefined - : passwordAction === "set" - ? password - : null, - permissions: permissions, - }) - } + + defaultValues={{ + username: prior?.user.username, + passwordAction: "leave", + permissions: { ...prior?.user.permissions }, + }} + onSuccess={onSuccess} > - setUsername(e.target.value)} required fullWidth + helperText=" " /> - - { - setPasswordAction(e.target.value as PasswordAction); - }} - > - {hasPassword && ( - <> - } - label="Leave set" - /> - } - label="Clear" - /> - - )} - {!hasPassword && ( - } - label="Leave unset" - /> - )} - - } - label="Set to" - /> - setPassword(e.target.value)} - /> - - + + + - + - +
); } diff --git a/ui/src/api.ts b/ui/src/api.ts index 7aaf748..67b5ea1 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -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. */