moonfire-nvr/ui/src/Login.tsx

171 lines
5.3 KiB
TypeScript

// This file is part of Moonfire NVR, a security camera network video recorder.
// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt.
// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception
import Avatar from "@material-ui/core/Avatar";
import Button from "@material-ui/core/Button";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogTitle from "@material-ui/core/DialogTitle";
import { makeStyles, Theme } from "@material-ui/core/styles";
import TextField from "@material-ui/core/TextField";
import LockOutlinedIcon from "@material-ui/icons/LockOutlined";
import Alert from "@material-ui/lab/Alert";
import React, { useEffect } from "react";
import * as api from "./api";
import { useSnackbars } from "./snackbars";
const useStyles = makeStyles((theme: Theme) => ({
avatar: {
backgroundColor: theme.palette.secondary.main,
},
noError: {
visibility: "hidden",
},
}));
interface Props {
open: boolean;
onSuccess: () => void;
handleClose: () => void;
}
/**
* Dialog for logging in.
*
* This is similar to <a
* href="https://github.com/mui-org/material-ui/tree/master/docs/src/pages/getting-started/templates/sign-in">the
* material-ui sign-in template</a>. On 401 error, it displays an error near
* the submit button; on other errors, it uses a (transient) snackbar.
*
* This doesn't quite follow Chromium's <a
* href="https://www.chromium.org/developers/design-documents/create-amazing-password-forms">creating</a>
* amazing password forms</a> recommendations: it doesn't prompt a navigation
* event on success. It's simpler to not mess with the history, and the current
* method appears to work with Chrome 88's built-in password manager. To be
* revisited if this causes problems.
*
* {@param open} should be true only when not logged in.
* {@param onSuccess} called when the user is successfully logged in and the
* cookie is set. The caller will have to do a new top-level API request to
* retrieve the CSRF token, as well as other data that wasn't available before
* logging in.
* {@param handleClose} called when a close was requested (by pressing escape
* or clicking outside the dialog). If the top-level API request fails when
* not logged in (the server is running without
* <tt>--allow-unauthenticated-permissions</tt>), the caller may ignore this.
*/
const Login = ({ open, onSuccess, handleClose }: Props) => {
const classes = useStyles();
const snackbars = useSnackbars();
// This is a simple uncontrolled form; use refs.
const usernameRef = React.useRef<HTMLInputElement>(null);
const passwordRef = React.useRef<HTMLInputElement>(null);
const [error, setError] = React.useState<string | null>(null);
const [pending, setPending] = React.useState<api.LoginRequest | null>(null);
useEffect(() => {
if (pending === null) {
return;
}
let abort = new AbortController();
const send = async (signal: AbortSignal) => {
let response = await api.login(pending, { signal });
switch (response.status) {
case "aborted":
break;
case "error":
if (response.httpStatus === 401) {
setError(response.message);
} else {
snackbars.enqueue({
message: response.message,
key: "login-error",
});
}
setPending(null);
break;
case "success":
setPending(null);
onSuccess();
}
};
send(abort.signal);
return () => {
abort.abort();
};
}, [pending, onSuccess, snackbars]);
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Suppress duplicate login attempts when latency is high.
// It'd be nice to also provide a visual indication, but we might
// just wait for LoadingButton in material-ui 5 rather than reinventing it.
if (pending !== null) {
return;
}
setPending({
username: usernameRef.current!.value,
password: passwordRef.current!.value,
});
};
return (
<Dialog
onClose={handleClose}
aria-labelledby="login-title"
open={open}
maxWidth="sm"
fullWidth={true}
>
<DialogTitle id="login-title">
<Avatar className={classes.avatar}>
<LockOutlinedIcon />
</Avatar>
Log in
</DialogTitle>
<form onSubmit={onSubmit}>
<TextField
id="username"
label="Username"
variant="filled"
required
autoComplete="username"
fullWidth
inputRef={usernameRef}
/>
<TextField
id="password"
label="Password"
variant="filled"
type="password"
required
autoComplete="current-password"
fullWidth
inputRef={passwordRef}
/>
{/* reserve space for an error; show when there's something to see */}
<Alert
severity="error"
className={error === null ? classes.noError : undefined}
>
{error}
</Alert>
<DialogActions>
<Button type="submit" variant="contained" color="secondary">
Log in
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default Login;