change password dialog in UI
This commit is contained in:
parent
88d7165c3e
commit
a6bdf0bd80
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
|
@ -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/<uuid>/<stream>/<recordings></tt>.
|
* <tt>GET /api/cameras/<uuid>/<stream>/<recordings></tt>.
|
||||||
|
|
Loading…
Reference in New Issue