330 lines
13 KiB
JavaScript
330 lines
13 KiB
JavaScript
import {
|
|
Box,
|
|
Button,
|
|
Checkbox, CircularProgress, Container,
|
|
FormControlLabel,
|
|
Grid,
|
|
Link, styled,
|
|
TextField,
|
|
Typography
|
|
} from "@mui/material";
|
|
|
|
import {Alert} from '@mui/lab';
|
|
import React, {useCallback, useContext, useEffect, useRef, useState} from "react";
|
|
import ReplayIcon from '@mui/icons-material/Replay';
|
|
import LanguageSelection from "../elements/language-selection";
|
|
import {decodeText, encodeText, getParameter, removeParameter} from "shared/util";
|
|
import {LocaleContext} from "shared/locale";
|
|
|
|
|
|
const LoginContainer = styled(Container)((props) => ({
|
|
marginTop: props.theme.spacing(5),
|
|
paddingBottom: props.theme.spacing(1),
|
|
borderColor: props.theme.palette.primary.main,
|
|
borderStyle: "solid",
|
|
borderWidth: 1,
|
|
borderRadius: 5,
|
|
"& h1 > img": {
|
|
marginRight: props.theme.spacing(2),
|
|
width: "60px",
|
|
height: "60px"
|
|
}
|
|
}));
|
|
|
|
const ResponseAlert = styled(Alert)((props) => ({
|
|
marginBottom: props.theme.spacing(2),
|
|
}));
|
|
|
|
export default function LoginForm(props) {
|
|
|
|
const api = props.api;
|
|
|
|
// inputs
|
|
let [username, setUsername] = useState("");
|
|
let [password, setPassword] = useState("");
|
|
let [rememberMe, setRememberMe] = useState(true);
|
|
let [emailConfirmed, setEmailConfirmed] = useState(null);
|
|
let [tfaCode, set2FACode] = useState("");
|
|
|
|
// 2fa
|
|
// 0: not sent, 1: sent, 2: retry
|
|
let [tfaToken, set2FAToken] = useState(api.user?.twoFactorToken || { authenticated: false, type: null, step: 0 });
|
|
let [error, setError] = useState("");
|
|
|
|
const abortController = new AbortController();
|
|
const abortSignal = abortController.signal;
|
|
|
|
// state
|
|
let [isLoggingIn, setLoggingIn] = useState(false);
|
|
let [loaded, setLoaded] = useState(false);
|
|
|
|
// ui
|
|
let passwordRef = useRef();
|
|
|
|
const {translate: L, currentLocale, requestModules} = useContext(LocaleContext);
|
|
|
|
const onUpdateLocale = useCallback(() => {
|
|
requestModules(api, ["general", "account"], currentLocale).then(data => {
|
|
setLoaded(true);
|
|
if (!data.success) {
|
|
alert(data.msg);
|
|
}
|
|
});
|
|
}, [currentLocale]);
|
|
|
|
useEffect(() => {
|
|
onUpdateLocale();
|
|
}, [currentLocale]);
|
|
|
|
const onLogin = useCallback(() => {
|
|
if (!isLoggingIn) {
|
|
setError("");
|
|
setLoggingIn(true);
|
|
removeParameter("success");
|
|
api.login(username, password, rememberMe).then((res) => {
|
|
let twoFactorToken = res.twoFactorToken || { };
|
|
set2FAToken({ ...twoFactorToken, authenticated: false, step: 0, error: "" });
|
|
setLoggingIn(false);
|
|
setPassword("");
|
|
if (!res.success) {
|
|
setEmailConfirmed(res.emailConfirmed);
|
|
setError(res.msg);
|
|
} else if (!twoFactorToken.type) {
|
|
props.onLogin();
|
|
}
|
|
});
|
|
}
|
|
}, [api, isLoggingIn, password, props, rememberMe, username]);
|
|
|
|
const onSubmit2FA = useCallback(() => {
|
|
setLoggingIn(true);
|
|
api.verifyTotp2FA(tfaCode).then((res) => {
|
|
setLoggingIn(false);
|
|
if (res.success) {
|
|
set2FAToken({ ...tfaToken, authenticated: true });
|
|
props.onLogin();
|
|
} else {
|
|
set2FAToken({ ...tfaToken, step: 2, error: res.msg });
|
|
}
|
|
});
|
|
}, [tfaCode, tfaToken, props]);
|
|
|
|
const onCancel2FA = useCallback(() => {
|
|
abortController.abort();
|
|
props.onLogout();
|
|
set2FAToken({authenticated: false, step: 0, error: ""});
|
|
}, [props, abortController]);
|
|
|
|
useEffect(() => {
|
|
if (!api.loggedIn) {
|
|
return;
|
|
}
|
|
|
|
if (!tfaToken || !tfaToken.confirmed || tfaToken.authenticated || tfaToken.type !== "fido") {
|
|
return;
|
|
}
|
|
|
|
let step = tfaToken.step || 0;
|
|
if (step !== 0) {
|
|
return;
|
|
}
|
|
|
|
console.log("navigator.credentials.get")
|
|
set2FAToken({ ...tfaToken, step: 1, error: "" });
|
|
navigator.credentials.get({
|
|
publicKey: {
|
|
challenge: encodeText(window.atob(tfaToken.challenge)),
|
|
allowCredentials: [{
|
|
id: encodeText(window.atob(tfaToken.credentialID)),
|
|
type: "public-key",
|
|
}],
|
|
userVerification: "discouraged",
|
|
},
|
|
signal: abortSignal
|
|
}).then((res) => {
|
|
let credentialID = res.id;
|
|
let clientDataJson = decodeText(res.response.clientDataJSON);
|
|
let authData = window.btoa(decodeText(res.response.authenticatorData));
|
|
let signature = window.btoa(decodeText(res.response.signature));
|
|
api.verifyKey2FA(credentialID, clientDataJson, authData, signature).then((res) => {
|
|
if (!res.success) {
|
|
set2FAToken({ ...tfaToken, step: 2, error: res.msg });
|
|
} else {
|
|
props.onLogin();
|
|
}
|
|
});
|
|
}).catch(e => {
|
|
set2FAToken({ ...tfaToken, step: 2, error: e.toString() });
|
|
});
|
|
}, [api.loggedIn, tfaToken, props.onLogin, props.onKey2FA, abortSignal]);
|
|
|
|
const createForm = () => {
|
|
|
|
// 2FA
|
|
if (api.loggedIn && tfaToken.type) {
|
|
|
|
if (tfaToken.type === "totp") {
|
|
return <>
|
|
<div>{L("account.2fa_title")}:</div>
|
|
<TextField
|
|
variant={"outlined"} margin={"normal"}
|
|
id={"code"} label={L("account.6_digit_code")} name={"code"}
|
|
autoComplete={"code"}
|
|
required fullWidth autoFocus
|
|
value={tfaCode} onChange={(e) => set2FACode(e.target.value)}
|
|
onKeyDown={e => e.key === "Enter" && onSubmit2FA()} />
|
|
{
|
|
tfaToken.error ? <ResponseAlert severity="error">{tfaToken.error}</ResponseAlert> : <></>
|
|
}
|
|
<Grid container spacing={2}>
|
|
<Grid item xs={6}>
|
|
<Button
|
|
fullWidth variant={"contained"}
|
|
color={"inherit"} size={"medium"}
|
|
disabled={isLoggingIn}
|
|
onClick={onCancel2FA}>
|
|
{L("general.go_back")}
|
|
</Button>
|
|
</Grid>
|
|
<Grid item xs={6}>
|
|
<Button
|
|
type="submit" fullWidth variant="contained"
|
|
color="primary" size={"medium"}
|
|
disabled={isLoggingIn || tfaToken.type !== "totp"}
|
|
onClick={onSubmit2FA}>
|
|
{isLoggingIn ?
|
|
<>{L("general.submitting")}… <CircularProgress size={15}/></> :
|
|
L("general.submit")
|
|
}
|
|
</Button>
|
|
</Grid>
|
|
</Grid>
|
|
</>
|
|
} else if (tfaToken.type === "fido") {
|
|
return <>
|
|
<div>{L("account.2fa_title")}:</div>
|
|
<br />
|
|
{L("account.2fa_text")}
|
|
<Box mt={2} textAlign={"center"}>
|
|
{tfaToken.step !== 2
|
|
? <CircularProgress/>
|
|
: <Box mb={2}>
|
|
<div><b>{L("general.something_went_wrong")}:</b><br />{tfaToken.error}</div>
|
|
<Button onClick={() => set2FAToken({ ...tfaToken, step: 0, error: "" })}
|
|
variant={"outlined"} color={"secondary"} size={"small"}>
|
|
<ReplayIcon /> {L("general.retry")}
|
|
</Button>
|
|
</Box>
|
|
}
|
|
</Box>
|
|
<Grid container spacing={2}>
|
|
<Grid item xs={6}>
|
|
<Button
|
|
fullWidth variant={"contained"}
|
|
color={"inherit"} size={"medium"}
|
|
disabled={isLoggingIn}
|
|
onClick={onCancel2FA}>
|
|
{L("general.go_back")}
|
|
</Button>
|
|
</Grid>
|
|
<Grid item xs={6}>
|
|
<Button
|
|
type="submit" fullWidth variant="contained"
|
|
color="primary" size={"medium"}
|
|
disabled={isLoggingIn || tfaToken.type !== "totp"}
|
|
onClick={onSubmit2FA}>
|
|
{isLoggingIn ?
|
|
<>{L("general.submitting")}… <CircularProgress size={15}/></> :
|
|
L("general.submit")
|
|
}
|
|
</Button>
|
|
</Grid>
|
|
</Grid>
|
|
</>
|
|
}
|
|
}
|
|
|
|
return <>
|
|
<TextField
|
|
variant={"outlined"} margin={"normal"}
|
|
label={L("account.username")} name={"username"}
|
|
autoComplete={"username"} disabled={isLoggingIn}
|
|
required fullWidth autoFocus
|
|
value={username} onChange={(e) => setUsername(e.target.value)}
|
|
onKeyDown={e => e.key === "Enter" && passwordRef.current && passwordRef.current.focus()}
|
|
/>
|
|
<TextField
|
|
variant={"outlined"} margin={"normal"}
|
|
name={"password"} label={L("account.password")} type={"password"}
|
|
autoComplete={"current-password"}
|
|
required fullWidth disabled={isLoggingIn}
|
|
value={password} onChange={(e) => setPassword(e.target.value)}
|
|
onKeyDown={e => e.key === "Enter" && onLogin()}
|
|
inputRef={passwordRef}
|
|
/>
|
|
<FormControlLabel
|
|
control={<Checkbox value="remember" color="primary"/>}
|
|
label={L("account.remember_me")}
|
|
checked={rememberMe} onClick={(e) => setRememberMe(!rememberMe)}
|
|
/>
|
|
{
|
|
error
|
|
? <ResponseAlert severity={"error"}>
|
|
{error}
|
|
{emailConfirmed === false
|
|
? <> <Link href={"/resendConfirmEmail"}>Click here</Link> to resend the confirmation email.</>
|
|
: <></>
|
|
}
|
|
</ResponseAlert>
|
|
: (successMessage
|
|
? <ResponseAlert severity={"success"}>{successMessage}</ResponseAlert>
|
|
: <></>)
|
|
}
|
|
<Button
|
|
type={"submit"} fullWidth variant={"contained"}
|
|
color={"primary"}
|
|
size={"large"}
|
|
disabled={isLoggingIn}
|
|
onClick={onLogin}>
|
|
{isLoggingIn ?
|
|
<>{L("account.signing_in")}… <CircularProgress size={15}/></> :
|
|
L("account.sign_in")
|
|
}
|
|
</Button>
|
|
<Grid container>
|
|
<Grid item xs>
|
|
<Link href="/resetPassword" variant="body2">
|
|
{L("account.forgot_password")}
|
|
</Link>
|
|
</Grid>
|
|
{ props.info.registrationAllowed ?
|
|
<Grid item>
|
|
<Link href="/register" variant="body2">
|
|
{L("account.register_text")}
|
|
</Link>
|
|
</Grid> : <></>
|
|
}
|
|
</Grid>
|
|
</>
|
|
}
|
|
|
|
if (!loaded) {
|
|
return <Box textAlign={"center"} mt={2}>
|
|
<h2>{L("general.loading", "Loading")}…</h2>
|
|
<CircularProgress size={"32px"}/>
|
|
</Box>
|
|
}
|
|
|
|
let successMessage = getParameter("success");
|
|
return <LoginContainer maxWidth={"xs"}>
|
|
<Box mt={2}>
|
|
<Typography component="h1" variant="h4">
|
|
<img src={"/img/icons/logo.png"} alt={"Logo"} height={48} />
|
|
{props.info.siteName}
|
|
</Typography>
|
|
{ createForm() }
|
|
<LanguageSelection api={api} />
|
|
</Box>
|
|
</LoginContainer>
|
|
}
|