change password dialog in UI

This commit is contained in:
Scott Lamb 2022-12-25 20:18:44 -05:00
parent 88d7165c3e
commit a6bdf0bd80
No known key found for this signature in database
4 changed files with 299 additions and 0 deletions

View File

@ -35,6 +35,7 @@ import ListItemText from "@mui/material/ListItemText";
import ListIcon from "@mui/icons-material/List"; import ListIcon from "@mui/icons-material/List";
import Videocam from "@mui/icons-material/Videocam"; import Videocam from "@mui/icons-material/Videocam";
import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemIcon from "@mui/material/ListItemIcon";
import ChangePassword from "./ChangePassword";
export type LoginState = export type LoginState =
| "unknown" | "unknown"
@ -54,6 +55,7 @@ function App() {
const [timeZoneName, setTimeZoneName] = useState<string | null>(null); const [timeZoneName, setTimeZoneName] = useState<string | null>(null);
const [fetchSeq, setFetchSeq] = useState(0); const [fetchSeq, setFetchSeq] = useState(0);
const [loginState, setLoginState] = useState<LoginState>("unknown"); const [loginState, setLoginState] = useState<LoginState>("unknown");
const [changePasswordOpen, setChangePasswordOpen] = useState<boolean>(false);
const [error, setError] = useState<api.FetchError | null>(null); const [error, setError] = useState<api.FetchError | null>(null);
const needNewFetch = () => setFetchSeq((seq) => seq + 1); const needNewFetch = () => setFetchSeq((seq) => seq + 1);
const snackbars = useSnackbars(); const snackbars = useSnackbars();
@ -125,6 +127,7 @@ function App() {
setLoginState("user-requested-login"); setLoginState("user-requested-login");
}} }}
logout={logout} logout={logout}
changePassword={() => setChangePasswordOpen(true)}
menuClick={toggleShowMenu} menuClick={toggleShowMenu}
activityMenuPart={activityMenuPart} activityMenuPart={activityMenuPart}
/> />
@ -176,6 +179,13 @@ function App() {
); );
}} }}
/> />
{toplevel?.user !== undefined && (
<ChangePassword
open={changePasswordOpen}
user={toplevel?.user}
handleClose={() => setChangePasswordOpen(false)}
/>
)}
{error !== null && ( {error !== null && (
<Container> <Container>
<h2>Error querying server</h2> <h2>Error querying server</h2>

View File

@ -19,6 +19,7 @@ interface Props {
loginState: LoginState; loginState: LoginState;
requestLogin: () => void; requestLogin: () => void;
logout: () => void; logout: () => void;
changePassword: () => void;
menuClick?: () => void; menuClick?: () => void;
activityMenuPart?: JSX.Element; activityMenuPart?: JSX.Element;
} }
@ -44,6 +45,11 @@ function MoonfireMenu(props: Props) {
props.logout(); props.logout();
}; };
const handleChangePassword = () => {
handleClose();
props.changePassword();
};
return ( return (
<> <>
<Toolbar variant="dense"> <Toolbar variant="dense">
@ -94,6 +100,9 @@ function MoonfireMenu(props: Props) {
open={Boolean(accountMenuAnchor)} open={Boolean(accountMenuAnchor)}
onClose={handleClose} onClose={handleClose}
> >
<MenuItem onClick={handleChangePassword}>
Change password
</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem> <MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu> </Menu>
</div> </div>

254
ui/src/ChangePassword.tsx Normal file
View File

@ -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<Request | null>(null);
const [currentPassword, setCurrentPassword] = React.useState("");
const [currentError, setCurrentError] = React.useState(false);
const [newPassword, setNewPassword] = React.useState<string>("");
const [newError, setNewError] = React.useState(false);
const [confirmPassword, setConfirmPassword] = React.useState<string>("");
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<HTMLFormElement>) => {
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<HTMLTextAreaElement | HTMLInputElement>
) => {
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<HTMLTextAreaElement | HTMLInputElement>
) => {
setConfirmPassword(e.target.value);
if (e.target.value === newPassword) {
setConfirmError(false);
}
};
const onBlurConfirmPassword = () => {
if (confirmPassword !== newPassword) {
setConfirmError(true);
}
};
return (
<Dialog
onClose={handleClose}
aria-labelledby="change-password-title"
open={open}
maxWidth="sm"
fullWidth={true}
>
<DialogTitle id="change-password-title">
Change password for {user.name}
</DialogTitle>
<form onSubmit={onSubmit}>
<DialogContent>
{/* The username is here in the hopes it will help password managers
* find the correct entry. It's otherwise unused. */}
<input
name="username"
type="hidden"
value={user.name}
autoComplete="username"
/>
<TextField
name="current-password"
label="Current password"
variant="filled"
type="password"
required
autoComplete="current-password"
fullWidth
error={currentError}
helperText={currentError ? "Current password is incorrect" : " "}
value={currentPassword}
onChange={(e) => {
setCurrentError(false);
setCurrentPassword(e.target.value);
}}
/>
<TextField
name="new-password"
label="New password"
variant="filled"
type="password"
required
autoComplete="new-password"
value={newPassword}
inputProps={{ minLength: MIN_PASSWORD_LENGTH }}
error={newError}
helperText={`Password must be at least ${MIN_PASSWORD_LENGTH} characters`}
fullWidth
onChange={onChangeNewPassword}
onBlur={onBlurNewPassword}
/>
<TextField
name="confirm-new-password"
label="Confirm new password"
variant="filled"
type="password"
required
autoComplete="new-password"
value={confirmPassword}
inputProps={{ minLength: MIN_PASSWORD_LENGTH }}
fullWidth
error={confirmError}
helperText="Passwords must match."
onChange={onChangeConfirmPassword}
onBlur={onBlurConfirmPassword}
/>
</DialogContent>
<DialogActions>
<LoadingButton
type="submit"
variant="contained"
color="secondary"
loading={loading !== null}
disabled={newError || confirmError}
>
Change
</LoadingButton>
</DialogActions>
</form>
</Dialog>
);
};
export default ChangePassword;

View File

@ -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 * Represents a range of one or more recordings as in a single array entry of
* <tt>GET /api/cameras/&lt;uuid>/&lt;stream>/&lt;recordings></tt>. * <tt>GET /api/cameras/&lt;uuid>/&lt;stream>/&lt;recordings></tt>.