feat(auth): add 2FA setup UI and login challenge step

This commit is contained in:
cesnimda
2026-07-12 21:17:09 +02:00
parent c68b49eda0
commit b85dc1ffb7
9 changed files with 702 additions and 57 deletions
+67 -47
View File
@@ -8,6 +8,7 @@ import { api, getApiErrorMessage } from "../api";
import { getRememberMePref, setAuthPersistencePreference } from "../auth";
import GoogleAuthCard from "../components/GoogleAuthCard";
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
import TwoFactorChallenge from "../components/TwoFactorChallenge";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
@@ -32,6 +33,7 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
const [loading, setLoading] = useState(false);
const [pendingToken, setPendingToken] = useState<string | null>(null);
const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard";
@@ -42,15 +44,23 @@ export default function LoginPage() {
.catch(() => setCfg(null));
}, []);
async function completeLogin() {
setAuthPersistencePreference(rememberMe ? "local" : "session");
await api.get("/auth/me");
toast(t("signedIn"), "success");
navigate(nextPath, { replace: true });
}
async function submit(mode: "login" | "register") {
setLoading(true);
try {
const url = mode === "register" ? "/auth/register" : "/auth/login";
await api.post(url, { email, password, rememberMe });
setAuthPersistencePreference(rememberMe ? "local" : "session");
await api.get("/auth/me");
toast(t("signedIn"), "success");
navigate(nextPath, { replace: true });
const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>(url, { email, password, rememberMe });
if (res.data?.requiresTwoFactor && res.data.pendingToken) {
setPendingToken(res.data.pendingToken);
return;
}
await completeLogin();
} catch (e: any) {
toast(getApiErrorMessage(e, t("loginFailed")), "error");
} finally {
@@ -80,53 +90,63 @@ export default function LoginPage() {
{cfg?.requireAuth ? t("authRequired") : t("authOptional")}
</Typography>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label={t("emailAndPassword")} />
<Tab label={t("google")} />
<Tab label={t("microsoft")} />
</Tabs>
{pendingToken ? (
<TwoFactorChallenge
pendingToken={pendingToken}
onCancel={() => setPendingToken(null)}
onSuccess={() => { setPendingToken(null); void completeLogin(); }}
/>
) : (
<>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label={t("emailAndPassword")} />
<Tab label={t("google")} />
<Tab label={t("microsoft")} />
</Tabs>
{tab === 0 && (
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" fullWidth />
<TextField label={t("profileCurrentPassword")} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth />
{tab === 0 && (
<Box component="form" onSubmit={(e) => { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
<TextField label={t("profileEmail")} value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" fullWidth />
<TextField label={t("profileCurrentPassword")} value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth />
<Box sx={{ display: "flex", alignItems: { xs: "flex-start", sm: "center" }, justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<FormControlLabel
control={<Checkbox disableRipple checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} />}
label={t("rememberMe")}
/>
<Button
type="button"
variant="text"
size="small"
disableRipple
onClick={() => navigate(`/forgot-password${email.trim() ? `?email=${encodeURIComponent(email.trim())}` : ""}`)}
sx={{ px: 0, minWidth: 0, fontWeight: 700, alignSelf: { xs: "stretch", sm: "auto" } }}
>
{t("forgotPassword")}
</Button>
</Box>
<Box sx={{ display: "flex", alignItems: { xs: "flex-start", sm: "center" }, justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<FormControlLabel
control={<Checkbox disableRipple checked={rememberMe} onChange={(e) => setRememberMe(e.target.checked)} />}
label={t("rememberMe")}
/>
<Button
type="button"
variant="text"
size="small"
disableRipple
onClick={() => navigate(`/forgot-password${email.trim() ? `?email=${encodeURIComponent(email.trim())}` : ""}`)}
sx={{ px: 0, minWidth: 0, fontWeight: 700, alignSelf: { xs: "stretch", sm: "auto" } }}
>
{t("forgotPassword")}
</Button>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary", mt: -0.5 }}>
{rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: -0.5 }}>
{rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")}
</Typography>
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end", mt: 1 }}>
{allowReg && (
<Button type="button" variant="outlined" disableRipple disabled={loading} onClick={() => void submit("register")}>
{t("createAccount")}
</Button>
)}
<Button type="submit" variant="contained" disableRipple disabled={loading}>
{t("signInTitle")}
</Button>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, justifyContent: "flex-end", mt: 1 }}>
{allowReg && (
<Button type="button" variant="outlined" disableRipple disabled={loading} onClick={() => void submit("register")}>
{t("createAccount")}
</Button>
)}
<Button type="submit" variant="contained" disableRipple disabled={loading}>
{t("signInTitle")}
</Button>
</Box>
</Box>
)}
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
{tab === 2 && <MicrosoftAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
</>
)}
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
{tab === 2 && <MicrosoftAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
</Paper>
</Box>
);
+3
View File
@@ -11,6 +11,7 @@ import { api, getApiErrorMessage } from "../api";
import GoogleAuthCard from "../components/GoogleAuthCard";
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
import AuthStatusCard from "../components/AuthStatusCard";
import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard";
import EmailProviderConnections from "../components/EmailProviderConnections";
import CropImageDialog from "../components/CropImageDialog";
import { useToast } from "../toast";
@@ -1348,6 +1349,8 @@ export default function ProfilePage() {
</Button>
</Box>
</Box>
{isLocal ? <TwoFactorSettingsCard /> : null}
</Paper>
);
}