From a6bdf0bd808d6cde09fd8949ce00a51804fb1b65 Mon Sep 17 00:00:00 2001 From: Scott Lamb Date: Sun, 25 Dec 2022 20:18:44 -0500 Subject: [PATCH] change password dialog in UI --- ui/src/App.tsx | 10 ++ ui/src/AppMenu.tsx | 9 ++ ui/src/ChangePassword.tsx | 254 ++++++++++++++++++++++++++++++++++++++ ui/src/api.ts | 26 ++++ 4 files changed, 299 insertions(+) create mode 100644 ui/src/ChangePassword.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b8b1e73..a034355 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -35,6 +35,7 @@ import ListItemText from "@mui/material/ListItemText"; import ListIcon from "@mui/icons-material/List"; import Videocam from "@mui/icons-material/Videocam"; import ListItemIcon from "@mui/material/ListItemIcon"; +import ChangePassword from "./ChangePassword"; export type LoginState = | "unknown" @@ -54,6 +55,7 @@ function App() { const [timeZoneName, setTimeZoneName] = useState(null); const [fetchSeq, setFetchSeq] = useState(0); const [loginState, setLoginState] = useState("unknown"); + const [changePasswordOpen, setChangePasswordOpen] = useState(false); const [error, setError] = useState(null); const needNewFetch = () => setFetchSeq((seq) => seq + 1); const snackbars = useSnackbars(); @@ -125,6 +127,7 @@ function App() { setLoginState("user-requested-login"); }} logout={logout} + changePassword={() => setChangePasswordOpen(true)} menuClick={toggleShowMenu} activityMenuPart={activityMenuPart} /> @@ -176,6 +179,13 @@ function App() { ); }} /> + {toplevel?.user !== undefined && ( + setChangePasswordOpen(false)} + /> + )} {error !== null && (

Error querying server

diff --git a/ui/src/AppMenu.tsx b/ui/src/AppMenu.tsx index 4c9f05d..a7ec22d 100644 --- a/ui/src/AppMenu.tsx +++ b/ui/src/AppMenu.tsx @@ -19,6 +19,7 @@ interface Props { loginState: LoginState; requestLogin: () => void; logout: () => void; + changePassword: () => void; menuClick?: () => void; activityMenuPart?: JSX.Element; } @@ -44,6 +45,11 @@ function MoonfireMenu(props: Props) { props.logout(); }; + const handleChangePassword = () => { + handleClose(); + props.changePassword(); + }; + return ( <> @@ -94,6 +100,9 @@ function MoonfireMenu(props: Props) { open={Boolean(accountMenuAnchor)} onClose={handleClose} > + + Change password + Logout diff --git a/ui/src/ChangePassword.tsx b/ui/src/ChangePassword.tsx new file mode 100644 index 0000000..73dc72b --- /dev/null +++ b/ui/src/ChangePassword.tsx @@ -0,0 +1,254 @@ +// This file is part of Moonfire NVR, a security camera network video recorder. +// Copyright (C) 2022 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. +// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception + +import LoadingButton from "@mui/lab/LoadingButton"; +import Dialog from "@mui/material/Dialog"; +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 React from "react"; +import * as api from "./api"; +import { useSnackbars } from "./snackbars"; + +interface Props { + user: api.ToplevelUser; + open: boolean; + handleClose: () => void; +} + +interface Request { + userId: number; + csrf: string; + currentPassword: 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; + +/** + * Dialog for changing password. + * + * There's probably a good set of best practices and even libraries for form + * validation. I don't know them, but I played with a few similar forms, and + * this code tries to behave similarly: + * + * - current password if the server has said the value is wrong and the form + * value hasn't changed. + * - new password on blurring the field or submit attempt if it doesn't meet + * validation rules (as opposed to showing errors while you're typing), + * cleared as soon as validation succeeds. + * - confirm password when new password changes away (unless confirm is empty), + * on blur, or on submit, cleared any time validation succeeds. + * + * The submit button is greyed on new/confirm password error. So it's initially + * clickable (to give you the idea of what to do) but will complain more visibly + * if you don't fill fields correctly first. + */ +const ChangePassword = ({ user, open, handleClose }: Props) => { + const snackbars = useSnackbars(); + const [loading, setLoading] = React.useState(null); + const [currentPassword, setCurrentPassword] = React.useState(""); + const [currentError, setCurrentError] = React.useState(false); + const [newPassword, setNewPassword] = React.useState(""); + const [newError, setNewError] = React.useState(false); + const [confirmPassword, setConfirmPassword] = React.useState(""); + const [confirmError, setConfirmError] = React.useState(false); + React.useEffect(() => { + if (loading === null) { + return; + } + let abort = new AbortController(); + const send = async (signal: AbortSignal) => { + let response = await api.updateUser( + loading.userId, + { + csrf: loading.csrf, + precondition: { + password: loading.currentPassword, + }, + update: { + password: loading.newPassword, + }, + }, + { signal } + ); + switch (response.status) { + case "aborted": + break; + case "error": + if (response.httpStatus === 412) { + if (currentPassword === loading.currentPassword) { + setCurrentError(true); + } + } else { + snackbars.enqueue({ + message: response.message, + key: "login-error", + }); + } + setLoading(null); + break; + case "success": + setLoading(null); + snackbars.enqueue({ + message: "Password changed successfully", + key: "password-changed", + }); + handleClose(); + } + }; + send(abort.signal); + return () => { + abort.abort(); + }; + }, [loading, handleClose, snackbars, currentPassword]); + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (newPassword.length < MIN_PASSWORD_LENGTH) { + setNewError(true); + return; + } else if (confirmPassword !== newPassword) { + setConfirmError(true); + return; + } + + // Suppress concurrent attempts. + if (loading !== null) { + return; + } + setLoading({ + userId: user.id, + csrf: user.session!.csrf, + currentPassword: currentPassword, + newPassword: newPassword, + }); + }; + + const onChangeNewPassword = ( + e: React.ChangeEvent + ) => { + setNewPassword(e.target.value); + if (e.target.value.length >= MIN_PASSWORD_LENGTH) { + setNewError(false); + } + if (e.target.value === confirmPassword) { + setConfirmError(false); + } + }; + const onBlurNewPassword = () => { + if (newPassword.length < MIN_PASSWORD_LENGTH) { + setNewError(true); + } + if (newPassword !== confirmPassword && confirmPassword !== "") { + setConfirmError(true); + } + }; + const onChangeConfirmPassword = ( + e: React.ChangeEvent + ) => { + setConfirmPassword(e.target.value); + if (e.target.value === newPassword) { + setConfirmError(false); + } + }; + const onBlurConfirmPassword = () => { + if (confirmPassword !== newPassword) { + setConfirmError(true); + } + }; + + return ( + + + Change password for {user.name} + + +
+ + {/* The username is here in the hopes it will help password managers + * find the correct entry. It's otherwise unused. */} + + + { + setCurrentError(false); + setCurrentPassword(e.target.value); + }} + /> + + + + + + + Change + + +
+
+ ); +}; + +export default ChangePassword; diff --git a/ui/src/api.ts b/ui/src/api.ts index 8c57b56..cc5c938 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -217,6 +217,32 @@ export async function logout(req: LogoutRequest, init: RequestInit) { }); } +export interface UpdateUserRequest { + csrf?: string; + precondition?: UserSubset; + update: UserSubset; +} + +export interface UserSubset { + password?: String; +} + +/** Updates a user. */ +export async function updateUser( + id: number, + req: UpdateUserRequest, + init: RequestInit +) { + return await myfetch(`/api/users/${id}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(req), + ...init, + }); +} + /** * Represents a range of one or more recordings as in a single array entry of * GET /api/cameras/<uuid>/<stream>/<recordings>.