331 lines
9.5 KiB
TypeScript
331 lines
9.5 KiB
TypeScript
import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom";
|
|
import {
|
|
Alert,
|
|
Avatar,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
Collapse,
|
|
Grid,
|
|
LinearProgress,
|
|
Link,
|
|
Paper,
|
|
TextField,
|
|
ToggleButton,
|
|
ToggleButtonGroup,
|
|
Typography
|
|
} from "@mui/material";
|
|
import { FormEvent, useState } from "react";
|
|
import { request } from "@/scripts/request.ts";
|
|
import { useUserinfo } from "@/stores/userinfo.tsx";
|
|
import LoginIcon from "@mui/icons-material/Login";
|
|
import SecurityIcon from "@mui/icons-material/Security";
|
|
import KeyIcon from "@mui/icons-material/Key";
|
|
import PasswordIcon from "@mui/icons-material/Password";
|
|
import EmailIcon from "@mui/icons-material/Email";
|
|
|
|
export default function SignInPage() {
|
|
const [panel, setPanel] = useState(0);
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const [factor, setFactor] = useState<number>();
|
|
const [factorType, setFactorType] = useState<any>();
|
|
|
|
const [factors, setFactors] = useState<any>(null);
|
|
const [challenge, setChallenge] = useState<any>(null);
|
|
|
|
const { readProfiles } = useUserinfo();
|
|
|
|
const [searchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
|
|
const handlers: any[] = [
|
|
async (evt: FormEvent<HTMLFormElement>) => {
|
|
evt.preventDefault();
|
|
|
|
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
|
|
if (!data.id) return;
|
|
|
|
setLoading(true);
|
|
const res = await request("/api/auth", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data)
|
|
});
|
|
if (res.status !== 200) {
|
|
setError(await res.text());
|
|
} else {
|
|
const data = await res.json();
|
|
setFactors(data["factors"]);
|
|
setChallenge(data["challenge"]);
|
|
setPanel(1);
|
|
setError(null);
|
|
}
|
|
setLoading(false);
|
|
},
|
|
async (evt: FormEvent<HTMLFormElement>) => {
|
|
evt.preventDefault();
|
|
|
|
if (!factor) return;
|
|
|
|
setLoading(true);
|
|
const res = await request(`/api/auth/factors/${factor}`, {
|
|
method: "POST"
|
|
});
|
|
if (res.status !== 200 && res.status !== 204) {
|
|
setError(await res.text());
|
|
} else {
|
|
const item = factors.find((item: any) => item.id === factor).type;
|
|
setError(null);
|
|
setPanel(2);
|
|
setFactorType(factorTypes[item]);
|
|
}
|
|
setLoading(false);
|
|
},
|
|
async (evt: SubmitEvent) => {
|
|
evt.preventDefault();
|
|
|
|
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
|
|
if (!data.credentials) return;
|
|
|
|
setLoading(true);
|
|
const res = await request(`/api/auth`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
challenge_id: challenge?.id,
|
|
factor_id: factor,
|
|
secret: data.credentials
|
|
})
|
|
});
|
|
if (res.status !== 200) {
|
|
setError(await res.text());
|
|
} else {
|
|
const data = await res.json();
|
|
if (data["is_finished"]) {
|
|
await grantToken(data["session"]["grant_token"]);
|
|
await readProfiles();
|
|
callback();
|
|
} else {
|
|
setError(null);
|
|
setPanel(1);
|
|
setFactor(undefined);
|
|
setFactorType(undefined);
|
|
setChallenge(data["challenge"]);
|
|
}
|
|
}
|
|
setLoading(false);
|
|
}
|
|
];
|
|
|
|
function callback() {
|
|
if (searchParams.has("closable")) {
|
|
window.close();
|
|
} else if (searchParams.has("redirect_uri")) {
|
|
window.open(searchParams.get("redirect_uri") ?? "/", "_self");
|
|
} else {
|
|
navigate("/users");
|
|
}
|
|
}
|
|
|
|
function getFactorAvailable(factor: any) {
|
|
const blacklist: number[] = challenge?.blacklist_factors ?? [];
|
|
return blacklist.includes(factor.id);
|
|
}
|
|
|
|
const factorTypes = [
|
|
{ icon: <PasswordIcon />, label: "Password Verification", autoComplete: "password" },
|
|
{ icon: <EmailIcon />, label: "Email One Time Password", autoComplete: "one-time-code" }
|
|
];
|
|
|
|
const elements = [
|
|
(
|
|
<>
|
|
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
|
|
<LoginIcon />
|
|
</Avatar>
|
|
<Typography component="h1" variant="h5">
|
|
Welcome back
|
|
</Typography>
|
|
<Box component="form" onSubmit={handlers[panel]} sx={{ mt: 3, width: "100%" }}>
|
|
<Grid container spacing={2}>
|
|
<Grid item xs={12}>
|
|
<TextField
|
|
autoComplete="username"
|
|
name="id"
|
|
required
|
|
fullWidth
|
|
label="Account ID"
|
|
helperText={"Use your username, email or phone number."}
|
|
autoFocus
|
|
/>
|
|
</Grid>
|
|
</Grid>
|
|
<Button
|
|
type="submit"
|
|
fullWidth
|
|
variant="contained"
|
|
disabled={loading}
|
|
sx={{ mt: 3, mb: 2 }}
|
|
>
|
|
{loading ? "Processing..." : "Next"}
|
|
</Button>
|
|
</Box>
|
|
</>
|
|
),
|
|
(
|
|
<>
|
|
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
|
|
<SecurityIcon />
|
|
</Avatar>
|
|
<Typography component="h1" variant="h5">
|
|
Verify that's you
|
|
</Typography>
|
|
<Box component="form" onSubmit={handlers[panel]} sx={{ mt: 3, width: "100%" }}>
|
|
<Grid container spacing={2}>
|
|
<Grid item xs={12}>
|
|
<ToggleButtonGroup
|
|
exclusive
|
|
orientation="vertical"
|
|
color="info"
|
|
value={factor}
|
|
sx={{ width: "100%" }}
|
|
onChange={(_, val) => setFactor(val)}
|
|
>
|
|
{factors?.map((item: any, idx: number) => (
|
|
<ToggleButton key={idx} value={item.id} disabled={getFactorAvailable(item)}>
|
|
<Grid container>
|
|
<Grid item xs={2}>
|
|
{factorTypes[item.type]?.icon}
|
|
</Grid>
|
|
<Grid item xs="auto">
|
|
{factorTypes[item.type]?.label}
|
|
</Grid>
|
|
</Grid>
|
|
</ToggleButton>
|
|
))}
|
|
</ToggleButtonGroup>
|
|
</Grid>
|
|
</Grid>
|
|
<Button
|
|
type="submit"
|
|
fullWidth
|
|
variant="contained"
|
|
disabled={loading}
|
|
sx={{ mt: 3, mb: 2 }}
|
|
>
|
|
{loading ? "Processing..." : "Next"}
|
|
</Button>
|
|
</Box>
|
|
</>
|
|
),
|
|
(
|
|
<>
|
|
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
|
|
<KeyIcon />
|
|
</Avatar>
|
|
<Typography component="h1" variant="h5">
|
|
Enter the credentials
|
|
</Typography>
|
|
<Box component="form" onSubmit={handlers[panel]} sx={{ mt: 3, width: "100%" }}>
|
|
<Grid container spacing={2}>
|
|
<Grid item xs={12}>
|
|
<TextField
|
|
autoComplete={factorType?.autoComplete ?? "password"}
|
|
name="credentials"
|
|
type="password"
|
|
required
|
|
fullWidth
|
|
label="Credentials"
|
|
autoFocus
|
|
/>
|
|
</Grid>
|
|
</Grid>
|
|
<Button
|
|
type="submit"
|
|
fullWidth
|
|
variant="contained"
|
|
disabled={loading}
|
|
sx={{ mt: 3, mb: 2 }}
|
|
>
|
|
{loading ? "Processing..." : "Next"}
|
|
</Button>
|
|
</Box>
|
|
</>
|
|
)
|
|
];
|
|
|
|
async function grantToken(tk: string) {
|
|
const res = await request("/api/auth/token", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
code: tk,
|
|
grant_type: "grant_token"
|
|
})
|
|
});
|
|
if (res.status !== 200) {
|
|
const err = await res.text();
|
|
setError(err);
|
|
throw new Error(err);
|
|
} else {
|
|
setError(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>}
|
|
|
|
<Collapse in={searchParams.has("redirect_uri")}>
|
|
<Alert severity="info" sx={{ mb: 2 }}>
|
|
You need sign in before take an action. After that, we will take you back to your work.
|
|
</Alert>
|
|
</Collapse>
|
|
|
|
<Card variant="outlined">
|
|
<Collapse in={loading}>
|
|
<LinearProgress />
|
|
</Collapse>
|
|
|
|
<CardContent
|
|
style={{ padding: "40px 48px 36px" }}
|
|
sx={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center"
|
|
}}
|
|
>
|
|
{elements[panel]}
|
|
</CardContent>
|
|
|
|
<Collapse in={challenge != null} unmountOnExit>
|
|
<Box>
|
|
<Paper square sx={{ pt: 3, px: 5, textAlign: "center" }}>
|
|
<Typography sx={{ mb: 2 }}>
|
|
Risk <b className="font-mono">{challenge?.risk_level}</b>
|
|
Progress <b className="font-mono">{challenge?.progress}/{challenge?.requirements}</b>
|
|
</Typography>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={challenge?.progress / challenge?.requirements * 100}
|
|
sx={{ width: "calc(100%+5rem)", mt: 1, mx: -5 }}
|
|
/>
|
|
</Paper>
|
|
</Box>
|
|
</Collapse>
|
|
</Card>
|
|
|
|
<Grid container justifyContent="center" sx={{ mt: 2 }}>
|
|
<Grid item>
|
|
<Link component={RouterLink} to="/auth/sign-up" variant="body2">
|
|
Haven't an account? Sign up!
|
|
</Link>
|
|
</Grid>
|
|
</Grid>
|
|
</>
|
|
);
|
|
} |