- {/* This is where the password reset link would go */}
+ {config.enable_reset_password && (
+
+
+
+ )}
{config.enable_signup && (
@@ -110,8 +129,67 @@ const Login = () => {
)}
+ setResetOpen(false)} />
);
};
+// ForgotPasswordDialog collects a username/email and asks the server to email a reset link. The
+// response is uniform, so the dialog always shows the same "if an account exists" confirmation.
+const ForgotPasswordDialog = (props) => {
+ const { t } = useTranslation();
+ const [identifier, setIdentifier] = useState("");
+ const [sending, setSending] = useState(false);
+ const [sent, setSent] = useState(false);
+
+ const handleSubmit = async () => {
+ try {
+ setSending(true);
+ await accountApi.requestPasswordReset(identifier);
+ } catch (e) {
+ console.log(`[Login] Password reset request failed`, e);
+ } finally {
+ setSending(false);
+ setSent(true); // Uniform outcome regardless of success/failure (enumeration-safe)
+ }
+ };
+
+ return (
+
+ );
+};
+
export default Login;
diff --git a/web/src/components/PasswordReset.jsx b/web/src/components/PasswordReset.jsx
new file mode 100644
index 00000000..0e004d04
--- /dev/null
+++ b/web/src/components/PasswordReset.jsx
@@ -0,0 +1,105 @@
+import * as React from "react";
+import { useEffect, useRef, useState } from "react";
+import { Typography, TextField, Button, Box } from "@mui/material";
+import WarningAmberIcon from "@mui/icons-material/WarningAmber";
+import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
+import { useParams, NavLink } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import accountApi from "../app/AccountApi";
+import AvatarBox from "./AvatarBox";
+import routes from "./routes";
+
+// PasswordReset is the magic-link landing page for setting a new password. There is no
+// pre-validation: the form renders directly and an invalid/expired token surfaces as an error on
+// submit. The raw token is stripped from the URL on load (kept out of history / Referer).
+const PasswordReset = () => {
+ const { t } = useTranslation();
+ const { token: tokenParam } = useParams();
+ const token = useRef(tokenParam);
+ const [password, setPassword] = useState("");
+ const [confirm, setConfirm] = useState("");
+ const [error, setError] = useState("");
+ const [sending, setSending] = useState(false);
+ const [done, setDone] = useState(false);
+
+ useEffect(() => {
+ // Strip the token from the URL bar immediately (keep it out of history / Referer)
+ window.history.replaceState(null, "", routes.login);
+ }, []);
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ if (password !== confirm) {
+ setError(t("reset_password_form_passwords_no_match"));
+ return;
+ }
+ try {
+ setSending(true);
+ setError("");
+ await accountApi.resetPassword(token.current, password);
+ setDone(true);
+ } catch (e) {
+ console.log(`[PasswordReset] Reset failed`, e);
+ setError(t("reset_password_form_error_invalid"));
+ } finally {
+ setSending(false);
+ }
+ };
+
+ if (done) {
+ return (
+
+
+ {t("reset_password_success_title")}
+ {t("reset_password_success_description")}
+
+
+ );
+ }
+
+ return (
+
+ {t("reset_password_title")}
+
+ setPassword(ev.target.value.trim())}
+ autoComplete="new-password"
+ autoFocus
+ />
+ setConfirm(ev.target.value.trim())}
+ autoComplete="new-password"
+ />
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+ );
+};
+
+export default PasswordReset;
diff --git a/web/src/components/routes.js b/web/src/components/routes.js
index d9c371eb..6e649df3 100644
--- a/web/src/components/routes.js
+++ b/web/src/components/routes.js
@@ -8,6 +8,7 @@ const routes = {
account: "/account",
settings: "/settings",
emailVerify: "/account/email/verify/:token",
+ passwordReset: "/account/password/reset/:token",
subscription: "/:topic",
subscriptionExternal: "/:baseUrl/:topic",
forSubscription: (subscription) => {