93 lines
4.4 KiB
JavaScript

import VisibilityOffOutlined from "@mui/icons-material/VisibilityOffOutlined";
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import CircularProgress from "@mui/material/CircularProgress";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx";
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
import { translateExternalError } from "../i18n/errors.js";
export default function LoginPage() {
const { initializing, login, session } = useExternalAuth();
const { t } = useExternalI18n();
const [form, setForm] = useState({ account: "", password: "" });
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
// 登录成功前没有可信 App 上下文;使用通用标题并清掉上一个会话的图标,避免泄露或误导用户所属 App。
globalThis.document.title = t("app.title");
globalThis.document.head.querySelector('link[data-external-app-favicon="true"]')?.remove();
}, [t]);
useEffect(() => {
if (!initializing && session) {
const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview";
navigate(destination, { replace: true });
}
}, [initializing, location.state, navigate, session]);
const handleSubmit = async (event) => {
event.preventDefault();
if (!form.account.trim() || !form.password) {
setError(t("auth.fillAccountPassword"));
return;
}
setSubmitting(true);
setError("");
try {
const nextSession = await login(form);
navigate(nextSession.passwordChangeRequired ? "/change-password" : "/overview", { replace: true });
} catch (requestError) {
// Every login 401 uses one generic credential message so unknown, disabled and locked accounts cannot be distinguished.
setError(requestError?.status === 401 ? t("auth.invalidCredentials") : translateExternalError(requestError, t, "auth.loginFailed"));
} finally {
setSubmitting(false);
}
};
return (
<Box className="external-login-page">
<Card className="external-login-card" elevation={0}>
<CardContent>
<Stack sx={{ alignItems: "flex-end" }}><LanguageSwitcher /></Stack>
<Stack spacing={1.5} sx={{ alignItems: "center" }}>
<Typography component="h1" variant="h5">{t("app.title")}</Typography>
</Stack>
<Stack component="form" spacing={2.25} onSubmit={handleSubmit}>
{error ? <Alert severity="error">{error}</Alert> : null}
<TextField autoComplete="username" disabled={submitting} label={t("auth.account")} required value={form.account} onChange={(event) => setForm({ ...form, account: event.target.value })} />
<TextField
autoComplete="current-password"
disabled={submitting}
label={t("auth.password")}
required
type={showPassword ? "text" : "password"}
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
slotProps={{ input: { endAdornment: <InputAdornment position="end"><IconButton aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")} edge="end" onClick={() => setShowPassword((current) => !current)}>{showPassword ? <VisibilityOffOutlined /> : <VisibilityOutlined />}</IconButton></InputAdornment> } }}
/>
<Button disabled={submitting} size="large" type="submit" variant="contained">
{submitting ? <CircularProgress color="inherit" size={20} /> : t("auth.login")}
</Button>
</Stack>
</CardContent>
</Card>
</Box>
);
}